From bd9ffe6faa426da3d586cfa3580a274426a3d016 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sat, 24 Oct 2026 01:54:41 +0200 Subject: [PATCH] Build compact live control view --- docs/ACCEPTANCE.md | 24 +- docs/ARCHITECTURE.md | 11 + docs/DECISIONS.md | 14 + docs/RELEASE_NOTES.md | 67 +- docs/STATUS.md | 31 +- src/app.css | 1181 ++++++++++++++++++++++------------ src/routes/+layout.server.ts | 3 +- src/routes/+layout.svelte | 2 + src/routes/live/+page.svelte | 983 ++++++++++++++++++---------- tests/ui.e2e.ts | 206 +++++- 10 files changed, 1735 insertions(+), 787 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 02d6395..805afd7 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -413,14 +413,24 @@ - reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen -## Verwaltungskorrektur 0.5.11 +## Kompakte Live-Regie und Verwaltungskorrektur 0.5.11 -| Kriterium | Nachweis | Stand | -| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- | -| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 23.10.2026 | -| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 23.10.2026 | -| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 23.10.2026 | -| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 23.10.2026 | +| Kriterium | Nachweis | Stand | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- | +| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 24.10.2026 | +| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 24.10.2026 | +| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 24.10.2026 | +| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 24.10.2026 | +| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 | +| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 | +| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 | +| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 | +| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 | +| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 | +| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 | +| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 | +| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 | +| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 | ## Ausgeführte Prüfungen 0.5.11 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c37d19..68abc5d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -283,6 +283,17 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht ohne interne Anwendungsnavigation. +Die private Route `/live` verwendet ebenfalls einen eigenen authentifizierten +Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder +Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster +aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie +fester Aktionszeile. Unterhalb des schmalen Breakpoints wird ausschließlich die +Ablaufspalte als modales Sheet mit Fokusbegrenzung eingeblendet; beim Wechsel +auf einen breiteren Viewport wird dieser Modalzustand aufgehoben. Dieser +Layoutmodus ändert weder die Live-Actions noch die getrennte öffentliche +`/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige Verwendung +gebunden und werden nicht zwischen Pieces übertragen. + Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter `static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 17687aa..57a8fa1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -360,3 +360,17 @@ Validierung, während ein statischer SVG-Sprite über den gemeinsamen Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und Lizenz stehen in `THIRD_PARTY_NOTICES.md`. + +## D-024: Private Live-Regie als fokussierter Viewport + +Die authentifizierte Live-Steuerung nutzt einen eigenen Root-Layoutmodus und +rendert die allgemeine App-Navigation nicht. Kopf- und Aktionszeile bleiben fest; +Moderation und Ablauf sind die einzigen unabhängig scrollenden Arbeitsflächen. +Die Umschaltung zum mobilen Sheet hängt ausschließlich von der Fensterbreite ab, +damit ein breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet. + +Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl, +Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte +serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein +Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche +Präsentationsaction verändert die getrennte öffentliche Ausgabe. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index ad14590..1f04f82 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,17 +1,72 @@ # Release Notes -## 0.5.11 - Verwaltungskorrektur +## 0.5.11 - Kompakte Live-Regie Veröffentlicht am 23. Oktober 2026 +Sendewerk 0.5.11 macht aus der privaten Live-Steuerung eine kompakte Regieansicht +für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Das +aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die +wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche +heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt. + +### Highlights + +- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale + App-Seitenleiste, mobilen App-Header oder Bottomnavigation. +- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den + tatsächlich präsentierten Ausgabezustand. +- Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen + Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer + Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar. +- Die feste Fußleiste bündelt nächstes Piece, „Präsentieren“, „Überspringen“ und + „Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende + eindeutig benannt. +- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet, + während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und + der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber. + +### Moderation und Materialien + +- Interner Titel, Einleitung, Moderationsstichpunkte und Überleitung erscheinen + in klarer Lesereihenfolge. Leere Bereiche werden weggelassen. +- Recherche, Quellen, Werkzeuge und eigene Notizen sind kompakt beschriftet und + werden nur bei Bedarf geöffnet. +- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal + gespeichert. Texte werden nicht automatisch verkleinert. + +### Ablauf und Liveverhalten + +- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und + das aktive Piece bleibt in der Ablaufspalte sichtbar. +- Pieces kennzeichnen „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch + textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags. +- Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und + die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über + eine kompakte Aktion am jeweiligen Eintrag erreichbar. +- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte + Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben. +- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts. + Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche + Ausgabe. +- Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme + verwenden unverändert die vorhandenen persistenten Zustände. + +### Einstellungen und Verwaltung + +- Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln, + Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar. - Der Dialog „Werkzeug hinzufügen“ bleibt beim Aufruf der Verwaltungsseite - wieder zuverlässig geschlossen. -- Der Dialog erscheint erst, wenn ein neues Werkzeug angelegt oder ein - bestehendes Werkzeug bearbeitet wird. + zuverlässig geschlossen und erscheint erst nach einer bewussten Aktion. - Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und Smartphone vollständig im sichtbaren Browserbereich. -- Die Korrektur gilt gleichermaßen für Desktop- und Smartphone-Ansichten und - verändert keine vorhandenen Werkzeuge oder Zuordnungen. + +### Update und Kompatibilität + +- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der + Konfiguration. +- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und + Präsentationssnapshots bleiben erhalten. ## 0.5.10 - Editor-Erlebnis diff --git a/docs/STATUS.md b/docs/STATUS.md index 2d28c4c..3042530 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -7,14 +7,31 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung, Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert. Etappe 6 wurde nicht begonnen. -## Verwaltungskorrektur 0.5.11 +## Kompakte Live-Regie 0.5.11 -- der Werkzeugdialog bleibt nach dem Laden der Verwaltungsseite geschlossen und - wird erst durch „Werkzeug hinzufügen“ oder die Bearbeitung eines vorhandenen - Werkzeugs als modaler Dialog eingeblendet -- die Iconauswahl der Themenverwaltung bleibt auch in der rechten Desktop-Spalte - vollständig innerhalb des sichtbaren Browserbereichs -- ein Browser-Regressionscheck sichert den geschlossenen Ausgangszustand ab +- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile + Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und + Ausgabekanal bleiben über das kompakte Regiemenü erreichbar +- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und + öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece, + Präsentation, Überspringen und kontextgerechtem Erledigen +- aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln + nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite + oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen +- auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet + als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen +- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, direkter + Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und + zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion + erreichbar, dauerhafte Sortierpfeile entfallen +- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece + beginnt oben statt an der Position des vorherigen Textes +- Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf; + leere Moderationsbereiche werden nicht dargestellt +- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation + verwenden unverändert ihre getrennten persistenten Zustände +- der Werkzeugdialog bleibt nach dem Laden der Verwaltungsseite geschlossen; + die Themen-Iconauswahl bleibt vollständig innerhalb des Viewports Etappe 6 bleibt unverändert unbegonnen. diff --git a/src/app.css b/src/app.css index 85f4465..f68da99 100644 --- a/src/app.css +++ b/src/app.css @@ -980,51 +980,191 @@ textarea:focus { font-size: 0.7rem; } +.live-controller-shell { + height: 100dvh; + min-height: 0; + margin-left: 0 !important; + padding: 0 !important; + overflow: hidden; +} + .live-page { - max-width: 88rem; + display: grid; + width: 100%; + height: 100dvh; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 0.55rem; + padding: max(0.65rem, env(safe-area-inset-top)) max(0.65rem, env(safe-area-inset-right)) + max(0.65rem, env(safe-area-inset-bottom)) max(0.65rem, env(safe-area-inset-left)); } -.live-header { - display: flex; + +.live-command-header { + display: grid; + position: relative; + z-index: 60; + grid-template-columns: minmax(12rem, 1fr) auto minmax(8rem, 0.55fr) auto; align-items: center; - justify-content: space-between; - gap: 1.25rem; - margin-bottom: 1rem; + gap: 0.65rem; + min-width: 0; + padding: 0.55rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line)); + border-radius: 0.9rem; + background: var(--glass); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(20px) saturate(1.08); } -.live-header h1 { - max-width: 24ch; - margin: 0.18rem 0 0; - font-size: clamp(1.5rem, 2.5vw, 2.1rem); - letter-spacing: -0.035em; - line-height: 1.08; + +.live-section-context, +.live-output-state { + display: flex; + min-width: 0; + align-items: center; + gap: 0.55rem; +} + +.live-section-context > div, +.live-output-state > div { + display: grid; + min-width: 0; +} + +.live-section-context small, +.live-output-state small, +.section-countdown span { + color: var(--text-faint); + font-size: 0.59rem; + font-weight: 720; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.live-section-context strong, +.live-output-state strong { + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; } .live-pulse { - display: inline-block; width: 0.48rem; height: 0.48rem; - margin-right: 0.35rem; + flex: 0 0 auto; border-radius: 50%; background: #d65c55; box-shadow: 0 0 0 0.25rem color-mix(in srgb, #d65c55 15%, transparent); } -.live-header-actions { - display: flex; - align-items: center; - gap: 0.7rem; +.section-countdown { + display: grid; + min-width: 6.8rem; + gap: 0.08rem; + padding: 0.38rem 0.55rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--panel-soft); + text-align: center; } -.live-header-actions > a { - color: var(--text-muted); - font-size: 0.75rem; - font-weight: 650; + +.section-countdown strong { + font-size: 0.78rem; + font-variant-numeric: tabular-nums; } + +.section-countdown.warning { + border-color: #f3ba63; + color: #b87600; +} + +.section-countdown.overrun { + border-color: #ff776d; + color: #d24d45; +} + +.live-output-state > span { + width: 0.5rem; + height: 0.5rem; + flex: 0 0 auto; + border-radius: 50%; + background: var(--text-faint); +} + +.live-output-state.active > span { + background: var(--accent); + box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent); +} + +.live-mobile-run-button { + display: none; +} + +.live-menu { + position: relative; + z-index: 40; + width: fit-content; + justify-self: end; +} + +.live-menu > summary { + width: fit-content; + padding: 0.48rem 0.65rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--panel-soft); + cursor: pointer; + font-size: 0.7rem; + font-weight: 750; + list-style: none; +} + +.live-menu-popover { + position: absolute; + z-index: 50; + top: calc(100% + 0.4rem); + right: 0; + display: grid; + width: min(22rem, calc(100vw - 1.3rem)); + max-height: calc(100dvh - 5rem); + overflow-y: auto; + padding: 0.7rem; + border: 1px solid var(--line-strong); + border-radius: 0.8rem; + background: var(--glass-strong); + box-shadow: var(--shadow-large); + backdrop-filter: blur(24px); +} + +.live-menu-popover section { + display: grid; + gap: 0.4rem; + padding: 0.6rem; +} + +.live-menu-popover section + section { + border-top: 1px solid var(--line); +} + +.live-menu-popover section > strong { + color: var(--text-faint); + font-size: 0.62rem; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.live-menu-popover a { + font-size: 0.72rem; + font-weight: 700; +} + .font-control { display: flex; + width: fit-content; padding: 0.18rem; border: 1px solid var(--line); border-radius: 0.65rem; background: var(--panel); } + .font-control button { width: 2.25rem; height: 2rem; @@ -1035,251 +1175,124 @@ textarea:focus { font-size: 0.75rem; font-weight: 700; } + .font-control button:hover { background: var(--panel-soft); } -.live-grid { +.live-message { + margin: 0; + padding: 0.45rem 0.65rem; + font-size: 0.7rem; +} + +.live-messages { display: grid; - grid-template-columns: minmax(0, 1.65fr) minmax(16rem, 0.7fr); - gap: 1rem; - align-items: start; + gap: 0.35rem; +} + +.live-messages:empty { + display: none; +} + +.live-workspace { + display: grid; + min-width: 0; + min-height: 0; + grid-template-columns: minmax(0, 1fr) minmax(15rem, 20rem); + gap: 0.65rem; +} + +.live-page.without-rail .live-workspace { + grid-template-columns: minmax(0, 1fr); +} + +.live-page.without-rail .live-rail { + display: none; } .live-current { min-width: 0; - padding: clamp(1.25rem, 3vw, 2.25rem); + min-height: 0; + overflow: hidden; border: 1px solid var(--line); - border-radius: 1.8rem; + border-radius: 1rem; background: var(--glass); - box-shadow: var(--shadow); + box-shadow: var(--shadow-soft); backdrop-filter: blur(20px) saturate(1.08); } +.live-current-scroll { + height: 100%; + overflow-y: auto; + padding: clamp(1rem, 2.2vw, 2rem); + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.live-piece-heading { + padding-bottom: 0.85rem; +} + .live-kicker { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; color: var(--accent); - font-size: 0.72rem; - font-weight: 700; + font-size: 0.65rem; + font-weight: 750; letter-spacing: 0.055em; text-transform: uppercase; } + .live-kicker strong { color: var(--text-muted); + font-variant-numeric: tabular-nums; } -.live-current h2 { - max-width: 20ch; - margin: 1rem 0 1.4rem; - font-size: clamp(1.8rem, 3.2vw, 2.8rem); - font-weight: 670; - letter-spacing: -0.05em; - line-height: 1.03; + +.live-current h1 { + max-width: 30ch; + margin: 0.45rem 0 0; + font-size: clamp(1.45rem, 2.7vw, 2.55rem); + font-weight: 690; + letter-spacing: -0.045em; + line-height: 1.04; + overflow-wrap: anywhere; } -.prompter-copy { - max-width: 34ch; + +.live-script-section { + max-width: 58rem; + padding: 1rem 0; + border-top: 1px solid var(--line); +} + +.live-script-section > span { + display: block; + margin-bottom: 0.45rem; + color: var(--accent); + font-size: 0.64rem; + font-weight: 780; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.prompter-copy, +.live-script-section .live-markdown { + max-width: 45ch; font-family: ui-serif, Georgia, serif; font-size: var(--prompt-size); line-height: 1.47; } -.moderation-card, +.moderation-card .live-markdown { + font-family: inherit; + font-size: max(1rem, calc(var(--prompt-size) * 0.82)); + line-height: 1.5; +} + .transition-card { - margin-top: 1.25rem; - padding: 1rem 1.1rem; - border-radius: 0.9rem; - background: color-mix(in srgb, var(--panel-soft) 66%, transparent); -} - -.moderation-card span, -.transition-card span { - color: var(--accent); - font-size: 0.67rem; - font-weight: 750; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.moderation-card p, -.transition-card p { - margin: 0.4rem 0 0; -} - -.research-details { - margin-top: 0.8rem; - padding: 0.82rem 1rem; - border: 1px solid var(--line); - border-radius: 0.85rem; - color: var(--text-muted); -} -.research-details summary { - cursor: pointer; - color: var(--text); - font-size: 0.78rem; - font-weight: 680; -} -.research-details p { - margin-bottom: 0; - font-size: 0.78rem; -} -.transition-card { - border-left: 3px solid var(--gold); - background: color-mix(in srgb, var(--gold) 8%, var(--panel)); -} - -.live-rail { - display: grid; - gap: 0.75rem; -} -.next-card, -.live-progress, -.readonly-actions { - padding: 1rem; - border: 1px solid var(--line); - border-radius: 1rem; - background: var(--glass); - box-shadow: var(--shadow-soft); - backdrop-filter: blur(16px) saturate(1.06); -} -.next-card > span { - color: var(--accent); - font-size: 0.66rem; - font-weight: 750; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.next-card h3 { - margin: 0.7rem 0 0.28rem; - font-size: 1rem; - line-height: 1.25; -} -.next-card p { - margin: 0; - color: var(--text-muted); - font-size: 0.7rem; -} -.live-progress header { - display: flex; - justify-content: space-between; - color: var(--text-muted); - font-size: 0.7rem; - font-weight: 680; -} -.progress-track { - height: 0.24rem; - margin: 0.7rem 0 1rem; - overflow: hidden; - border-radius: 1rem; - background: var(--panel-soft); -} -.progress-track span { - display: block; - width: 45%; - height: 100%; - border-radius: inherit; - background: var(--gold); -} -.rail-section { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.6rem; - padding: 0.55rem; - border-radius: 0.6rem; - color: var(--text-muted); -} -.rail-section > span { - font-size: 0.63rem; - font-weight: 720; -} -.rail-section div { - display: flex; - justify-content: space-between; - gap: 0.6rem; -} -.rail-section strong { - font-size: 0.7rem; -} -.rail-section small { - font-size: 0.64rem; -} -.rail-section.active { - background: color-mix(in srgb, var(--gold) 10%, var(--panel)); - color: var(--text); -} -.readonly-actions { - display: flex; - justify-content: space-between; - gap: 0.5rem; - color: var(--text-faint); - font-size: 0.63rem; -} -.readonly-actions strong { - color: var(--text-muted); - font-size: 0.61rem; -} - -.live-state-line { - margin: 0.4rem 0 0; - color: var(--text-muted); - font-size: 0.75rem; -} - -.live-toolbar { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.55rem; - margin-bottom: 1rem; - padding: 0.75rem; - border: 1px solid var(--line); - border-radius: 1rem; - border-color: color-mix(in srgb, var(--gold) 16%, var(--line)); - background: var(--glass); - box-shadow: var(--shadow-soft); - backdrop-filter: blur(20px) saturate(1.08); -} - -.live-session-summary { - display: grid; - margin-left: auto; - text-align: right; -} - -.live-output-settings { - position: relative; -} - -.live-output-settings > summary { - cursor: pointer; - color: var(--text-muted); - font-size: 0.7rem; - font-weight: 700; -} - -.live-output-settings > div { - position: absolute; - z-index: 4; - top: calc(100% + 0.45rem); - left: 0; - display: grid; - width: max-content; - max-width: calc(100vw - 2rem); - gap: 0.35rem; - padding: 0.6rem; - border: 1px solid var(--line-strong); - border-radius: var(--radius-small); - background: var(--glass-strong); - box-shadow: var(--shadow); -} - -.live-session-summary strong { - font-size: 0.78rem; -} - -.live-session-summary span { - color: var(--text-muted); - font-size: 0.65rem; -} - -.live-grid.without-rail { - grid-template-columns: minmax(0, 1fr); + border-top-color: color-mix(in srgb, var(--gold) 50%, var(--line)); } .live-markdown > :first-child, @@ -1292,18 +1305,193 @@ textarea:focus { margin-bottom: 0; } -.live-current-actions { +.live-support-actions { display: flex; - align-items: center; + align-items: flex-start; flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1.4rem; - padding-top: 1rem; + gap: 0.45rem; + padding-top: 0.9rem; border-top: 1px solid var(--line); } -.live-current-actions form:last-child { - margin-left: auto; +.research-details, +.live-materials { + min-width: min(100%, 15rem); + max-width: min(100%, 34rem); + padding: 0.55rem 0.65rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: color-mix(in srgb, var(--panel-soft) 76%, transparent); +} + +.research-details summary, +.live-materials summary { + cursor: pointer; + font-size: 0.7rem; + font-weight: 740; +} + +.research-details .live-markdown, +.live-materials > :not(summary) { + margin-top: 0.65rem; +} + +.research-details .live-markdown, +.live-materials p, +.live-materials li { + font-size: 0.76rem; + line-height: 1.5; +} + +.live-materials ul { + display: grid; + gap: 0.5rem; + padding: 0; + list-style: none; +} + +.live-materials li, +.live-materials small { + display: block; +} + +.live-materials small { + color: var(--text-muted); +} + +.live-empty-copy { + color: var(--text-muted); +} + +.live-empty { + display: grid; + place-items: center; + padding: 1rem; + text-align: center; +} + +.live-empty > div { + max-width: 34rem; +} + +.live-empty h1 { + margin-inline: auto; +} + +.live-rail { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr); + padding: 0.65rem; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--glass); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(18px) saturate(1.06); +} + +.live-rail > header, +.live-reserve > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; +} + +.live-rail > header > div { + display: grid; +} + +.live-rail > header strong { + font-size: 0.8rem; +} + +.live-rail > header span { + color: var(--text-muted); + font-size: 0.62rem; +} + +.live-rail > header > button { + display: none; + width: 2rem; + height: 2rem; + place-items: center; + padding: 0; +} + +.progress-track { + height: 0.22rem; + margin: 0.55rem 0; + overflow: hidden; + border-radius: 1rem; + background: var(--panel-soft); +} + +.progress-track span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--gold); +} + +.live-run-scroll { + min-height: 0; + overflow-y: auto; + padding-right: 0.15rem; + overscroll-behavior: contain; +} + +.rail-section { + border-top: 1px solid var(--line); +} + +.rail-section > summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.55rem; + padding: 0.55rem 0.25rem; + cursor: pointer; + list-style: none; +} + +.rail-section > summary > span { + padding-top: 0.08rem; + color: var(--text-faint); + font-size: 0.59rem; + font-weight: 760; +} + +.rail-section > summary > div { + display: grid; + min-width: 0; +} + +.rail-section > summary strong { + font-size: 0.7rem; + overflow-wrap: anywhere; +} + +.rail-section > summary small { + color: var(--text-faint); + font-size: 0.58rem; +} + +.rail-section.active > summary { + color: var(--gold-strong); +} + +.rail-section-items { + display: grid; + gap: 0.3rem; + padding: 0 0 0.45rem; +} + +.rail-section-items > p { + margin: 0 0.25rem 0.35rem; + color: var(--text-faint); + font-size: 0.64rem; } .live-run-item, @@ -1311,75 +1499,379 @@ textarea:focus { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 0.5rem; - margin-top: 0.35rem; - padding: 0.5rem 0.55rem; - border-radius: 0.65rem; - background: color-mix(in srgb, var(--panel-soft) 68%, transparent); + gap: 0.3rem; + border: 1px solid transparent; + border-radius: 0.6rem; + background: color-mix(in srgb, var(--panel-soft) 70%, transparent); } .live-run-item.current { - box-shadow: inset 3px 0 var(--gold); - background: color-mix(in srgb, var(--gold) 9%, var(--panel)); + border-color: color-mix(in srgb, var(--gold) 55%, var(--line)); + background: color-mix(in srgb, var(--gold) 10%, var(--panel)); } -.live-run-item > div:first-child, -.live-reserve-item > div:first-child { +.live-run-item.done { + opacity: 0.68; +} + +.live-run-item.skipped { + border-style: dashed; + opacity: 0.62; +} + +.live-run-item > form:first-child { + min-width: 0; +} + +.live-run-actions, +.live-reserve-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.2rem; +} + +.live-run-actions form, +.live-reserve-actions form { + display: flex; +} + +.live-run-order { + position: relative; +} + +.live-run-order > summary, +.live-run-actions > form button { + display: grid; + width: 1.8rem; + height: 1.8rem; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: 0.45rem; + background: transparent; + color: var(--text-muted); + cursor: pointer; + list-style: none; +} + +.live-run-order > summary:hover, +.live-run-order[open] > summary, +.live-run-actions > form button:hover { + border-color: var(--line); + background: var(--panel-soft); + color: var(--text); +} + +.live-run-order > div { + display: flex; + position: absolute; + z-index: 10; + top: calc(100% + 0.2rem); + right: 0; + gap: 0.2rem; + padding: 0.25rem; + border: 1px solid var(--line-strong); + border-radius: 0.55rem; + background: var(--glass-strong); + box-shadow: var(--shadow-soft); +} + +.live-run-order > div button { + display: grid; + width: 1.8rem; + height: 1.8rem; + place-items: center; + padding: 0; +} + +.live-run-select { + display: grid; + width: 100%; + min-width: 0; + padding: 0.45rem 0.5rem; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; +} + +.live-run-select:disabled { + cursor: default; + opacity: 1; +} + +.live-run-select strong, +.live-reserve-item strong { + font-size: 0.67rem; + overflow-wrap: anywhere; +} + +.live-run-select small, +.live-reserve-item small { + color: var(--text-faint); + font-size: 0.56rem; +} + +.live-run-actions { + margin-right: 0.28rem; +} + +.live-reserve { + margin-top: 0.45rem; + border-top: 1px solid color-mix(in srgb, var(--gold) 42%, var(--line)); +} + +.live-reserve > summary { + padding: 0.6rem 0.25rem; + cursor: pointer; + color: var(--gold-strong); + font-size: 0.68rem; + font-weight: 760; + list-style: none; +} + +.live-reserve > div { + display: grid; + gap: 0.3rem; + padding-bottom: 0.35rem; +} + +.live-reserve-item { + padding: 0.4rem 0.5rem; +} + +.live-reserve-item > div { display: grid; min-width: 0; } -.live-run-item strong, -.live-reserve-item strong { - font-size: 0.7rem; - overflow-wrap: anywhere; +.live-reserve-item > form { + display: flex; } -.live-run-item small, -.live-reserve-item small { +.live-reserve-actions { + min-width: 0; +} + +.live-reserve-item > form button { + font-size: 0.6rem; +} + +.live-reserve p { + margin: 0.4rem; color: var(--text-muted); - font-size: 0.61rem; + font-size: 0.64rem; } -.live-run-actions { +.live-rail-backdrop { + display: none; +} + +.live-action-bar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + padding: 0.55rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line)); + border-radius: 0.9rem; + background: var(--glass-strong); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(22px) saturate(1.08); +} + +.live-next-summary { + display: grid; + min-width: 0; +} + +.live-next-summary span { + color: var(--accent); + font-size: 0.58rem; + font-weight: 760; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.live-next-summary strong { + overflow: hidden; + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-footer-actions { display: flex; align-items: center; - gap: 0.2rem; + justify-content: flex-end; + gap: 0.4rem; } -.live-run-actions form { +.live-footer-actions form { display: flex; } -.live-run-actions button:not(.text-button) { - min-width: 1.65rem; - min-height: 1.65rem; - padding: 0; +.live-footer-actions button { + white-space: nowrap; } -.live-run-actions .text-button { - padding: 0.3rem; - font-size: 0.62rem; +.live-finished-label { + color: var(--gold-strong); + font-size: 0.75rem; } -.live-reserve { - padding: 1rem; - border: 1px solid color-mix(in srgb, var(--gold) 34%, var(--line)); - border-radius: 1rem; - background: color-mix(in srgb, var(--gold) 5%, var(--panel)); -} +@media (max-width: 46rem) { + .live-page { + gap: 0.4rem; + padding: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right)) + max(0.4rem, env(safe-area-inset-bottom)) max(0.4rem, env(safe-area-inset-left)); + } -.live-reserve > header { - display: flex; - justify-content: space-between; - font-size: 0.7rem; - font-weight: 720; -} + .live-command-header { + grid-template-areas: + 'context menu' + 'countdown output' + 'run run'; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.4rem; + padding: 0.45rem; + } -.live-reserve > p { - margin: 0.65rem 0 0; - color: var(--text-muted); - font-size: 0.68rem; + .live-section-context { + grid-area: context; + } + + .section-countdown { + grid-area: countdown; + min-width: 0; + text-align: left; + } + + .live-output-state { + grid-area: output; + min-width: 0; + padding-inline: 0.35rem; + } + + .live-menu { + grid-area: menu; + } + + .live-mobile-run-button { + display: block; + grid-area: run; + width: 100%; + padding: 0.42rem 0.55rem; + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--panel-soft); + color: var(--text); + font-size: 0.67rem; + font-weight: 740; + } + + .live-desktop-rail-toggle { + display: none; + } + + .live-workspace, + .live-page.without-rail .live-workspace { + grid-template-columns: minmax(0, 1fr); + } + + .live-page.without-rail .live-rail, + .live-rail { + display: grid; + position: fixed; + z-index: 80; + inset: max(0.4rem, env(safe-area-inset-top)) max(0.4rem, env(safe-area-inset-right)) + max(0.4rem, env(safe-area-inset-bottom)) auto; + width: min(22rem, calc(100vw - 0.8rem - env(safe-area-inset-left))); + transform: translateX(calc(100% + 1rem)); + visibility: hidden; + transition: + transform 160ms ease, + visibility 0s linear 160ms; + } + + .live-rail.open { + transform: translateX(0); + visibility: visible; + transition-delay: 0s; + } + + .live-rail > header > button { + display: grid; + } + + .live-rail-backdrop.visible { + display: block; + position: fixed; + z-index: 70; + inset: 0; + border: 0; + background: color-mix(in srgb, var(--bg) 56%, transparent); + backdrop-filter: blur(3px); + } + + .live-current-scroll { + padding: 0.9rem; + } + + .live-current h1 { + font-size: clamp(1.35rem, 7vw, 2rem); + } + + .live-support-actions { + display: grid; + } + + .research-details, + .live-materials { + width: 100%; + min-width: 0; + } + + .live-action-bar { + display: grid; + gap: 0.4rem; + padding: 0.45rem; + } + + .live-next-summary strong { + font-size: 0.68rem; + } + + .live-footer-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.3rem; + } + + .live-footer-actions form, + .live-footer-actions button { + width: 100%; + } + + .live-footer-actions button { + min-height: 2.2rem; + padding-inline: 0.4rem; + white-space: normal; + overflow-wrap: anywhere; + font-size: 0.64rem; + } + + .live-clear-form { + grid-column: 1 / -1; + } + + .live-finished-label { + grid-column: 1 / -1; + text-align: center; + } } .heinzlive-output { @@ -1451,75 +1943,6 @@ textarea:focus { text-transform: uppercase; } -.section-countdown { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; - padding: 0.8rem 1rem; - border: 1px solid var(--line); - border-radius: 0.65rem; - background: var(--surface-raised); -} - -.section-countdown strong { - font-variant-numeric: tabular-nums; -} - -.section-countdown.warning { - border-color: #f3ba63; - color: #f3ba63; -} - -.section-countdown.overrun { - border-color: #ff776d; - color: #ff776d; -} - -.live-materials { - display: grid; - grid-template-columns: minmax(9rem, 0.45fr) repeat(2, minmax(0, 1fr)); - gap: 1rem; - margin-top: 1rem; - padding: 1rem; - border: 1px solid var(--line); - border-radius: 0.7rem; - background: var(--surface-raised); -} - -.live-materials h3, -.live-materials ul { - margin: 0; -} - -.live-materials ul { - display: grid; - gap: 0.45rem; - padding: 0; - list-style: none; -} - -.live-material-group li, -.live-material-group small { - display: block; -} - -.live-material-group small { - color: var(--text-muted); -} - -@media (max-width: 760px) { - .live-materials { - grid-template-columns: 1fr; - } - - .section-countdown { - align-items: flex-start; - flex-direction: column; - } -} - .output-content h1, .output-idle h1 { max-width: 16ch; @@ -5387,39 +5810,6 @@ textarea:focus { .section-editor { margin-left: 0; } - .live-header-actions { - width: 100%; - overflow-x: auto; - justify-content: flex-start; - padding-bottom: 0.2rem; - } - .live-header-actions > * { - flex: 0 0 auto; - } - .live-grid { - grid-template-columns: 1fr; - } - .live-current { - padding: 1.3rem; - border-radius: 1.3rem; - } - .live-current-actions { - align-items: stretch; - } - .live-current-actions form:last-child { - margin-left: 0; - } - .live-current h2 { - margin: 1.1rem 0 1.5rem; - } - .live-kicker { - align-items: flex-start; - flex-direction: column; - gap: 0.2rem; - } - .live-rail { - grid-template-columns: 1fr; - } .settings-grid { grid-template-columns: 1fr; } @@ -5478,19 +5868,6 @@ body:has(.heinzlive-output.empty), padding: 0; } -.live-toolbar { - position: relative; - z-index: 10; -} - -.live-output-settings[open] { - z-index: 50; -} - -.live-output-settings > div { - z-index: 50; -} - .template-popover { width: min(64rem, calc(100vw - var(--sidebar-width) - 4rem)); max-height: calc(100dvh - 8rem); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 6e6db09..fd46542 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -2,5 +2,6 @@ import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = ({ locals, url }) => ({ user: url.pathname.startsWith('/heinzlive/') ? null : locals.user, - presentationMode: url.pathname.startsWith('/heinzlive/') + presentationMode: url.pathname.startsWith('/heinzlive/'), + liveControllerMode: url.pathname === '/live' }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a9a291a..d7be73a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -34,6 +34,8 @@ {#if data.presentationMode} {@render children()} +{:else if data.user && data.liveControllerMode} +
{@render children()}
{:else if data.user}
{/if} + {#if !data.live.reserve.length}

Keine Pieces in Reserve.

{/if} +
+ + + + + diff --git a/tests/ui.e2e.ts b/tests/ui.e2e.ts index 3b1b90b..ee86f70 100644 --- a/tests/ui.e2e.ts +++ b/tests/ui.e2e.ts @@ -1003,6 +1003,7 @@ test('validated YouTube and TikTok players switch without exposing private field }) ); await page.goto('/live?show=e2e-presentation'); + await page.getByText('Menü', { exact: true }).click(); const popupPromise = page.waitForEvent('popup'); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); const output = await popupPromise; @@ -1076,10 +1077,14 @@ test('live control persists state and exposes only explicit presentation data', await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click(); await page.goto('/live?show=show-october'); - await expect(page.getByText('Noch nicht gestartet.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Bereit für die Sendung' })).toBeVisible(); + await expect(page.locator('.sidebar')).toHaveCount(0); + await expect(page.locator('.mobile-header')).toHaveCount(0); + await expect(page.locator('.mobile-navigation')).toHaveCount(0); await page.getByRole('button', { name: 'Sendung starten' }).click(); await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); - await expect(page.locator('.section-countdown')).toContainText(/Noch 0[45]:\d{2}/); + await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/); + await page.getByText('Quellen · 1', { exact: true }).click(); await expect(page.locator('.live-materials')).toContainText('Herstellerseite'); await expect( page.getByText('Herstellerangaben gegen unabhängige Tests prüfen') @@ -1088,15 +1093,17 @@ test('live control persists state and exposes only explicit presentation data', const before = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); + await page.getByText('Menü', { exact: true }).click(); await page.getByRole('button', { name: 'Schrift vergrößern' }).click(); await page.reload(); const restored = await page .locator('.live-current') .evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size')); expect(restored).not.toBe(before); - await page.getByText('Manuelle Recherche öffnen').click(); + await page.locator('.research-details > summary').click(); await expect(page.locator('.research-details')).toHaveAttribute('open', ''); + await page.getByText('Menü', { exact: true }).click(); const [popup] = await Promise.all([ page.waitForEvent('popup'), page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click() @@ -1140,28 +1147,201 @@ test('live control persists state and exposes only explicit presentation data', await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); await page.getByRole('button', { name: 'Präsentieren', exact: true }).click(); + await page.getByText('Reserve', { exact: true }).click(); await page.getByRole('button', { name: 'Als Nächstes' }).click(); - await expect(page.locator('.next-card')).toContainText('Pixelart'); - await page.getByRole('button', { name: 'Erledigen und weiter' }).click(); + await expect(page.locator('.live-next-summary')).toContainText('Pixelart'); + await page.getByRole('button', { name: 'Erledigen & weiter' }).click(); await expect(page.locator('.live-current')).toContainText('Pixelart'); await page.getByRole('button', { name: 'Überspringen' }).click(); - await page.getByRole('button', { name: 'Zurück' }).click(); - await expect(page.locator('.live-current')).toContainText('Pixelart'); + await expect(page.locator('#current-title')).toHaveText( + 'Warum praktische Effekte wieder auffallen' + ); + await expect( + page.locator('.live-run-item').filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' }) + ).toContainText('Erledigt'); + await expect( + page.locator('.live-run-item').filter({ hasText: 'Pixelart, die nicht nostalgisch sein will' }) + ).toContainText('Übersprungen'); + await expect( + page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) + ).toContainText('Aktuell'); + await page.locator('.live-current-scroll').evaluate((element) => { + for (let index = 0; index < 12; index += 1) { + const paragraph = document.createElement('p'); + paragraph.textContent = 'Temporärer Langtext für die Lesepositionsprüfung.'; + element.append(paragraph); + } + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event('scroll')); + }); + expect( + await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop) + ).toBeGreaterThan(0); + + await page + .getByRole('button', { + name: 'Die kleine Konsole mit dem großen Dreh als aktuelles Piece auswählen' + }) + .click(); + await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); + await expect + .poll(() => page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)) + .toBe(0); + await expect( + page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) + ).toContainText('Kommend'); + await expect( + output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) + ).toBeVisible(); + await page + .getByRole('button', { + name: 'Warum praktische Effekte wieder auffallen als aktuelles Piece auswählen' + }) + .click(); + await expect(page.locator('#current-title')).toHaveText( + 'Warum praktische Effekte wieder auffallen' + ); + await expect(output.getByText('Wenn das Licht wirklich auf ein Modell fällt.')).toHaveCount(0); + + await page.setViewportSize({ width: 1440, height: 900 }); + await setTheme(page, 'dark'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expectNoHorizontalOverflow(page); + await page.screenshot({ path: testInfo.outputPath('live-controller-desktop-dark.png') }); + + await page.setViewportSize({ width: 900, height: 450 }); + await setTheme(page, 'light'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.evaluate(() => { + const title = document.querySelector('#current-title'); + if (title) + title.textContent = + 'Warum praktische Effekte in einer außergewöhnlich langen Live-Moderation wieder auffallen'; + const copy = document.querySelector('.prompter-copy'); + if (copy) { + for (let index = 0; index < 8; index += 1) { + const paragraph = document.createElement('p'); + paragraph.textContent = + 'Zusätzlicher langer Moderationstext prüft die unabhängige Scrollfläche ohne die feste Bedienleiste zu verdrängen.'; + copy.append(paragraph); + } + } + const rail = document.querySelector('.live-run-scroll'); + const section = rail?.querySelector('.rail-section'); + if (rail && section) { + for (let index = 0; index < 8; index += 1) rail.append(section.cloneNode(true)); + } + }); + const compactLayout = await page.evaluate(() => { + const current = document.querySelector('.live-current')?.getBoundingClientRect(); + const rail = document.querySelector('.live-rail')?.getBoundingClientRect(); + const footer = document.querySelector('.live-action-bar')?.getBoundingClientRect(); + const pieceScroll = document.querySelector('.live-current-scroll'); + const railScroll = document.querySelector('.live-run-scroll'); + return { + pageFits: document.documentElement.scrollHeight <= innerHeight, + twoColumns: Boolean(current && rail && rail.x > current.x + current.width - 1), + footerVisible: Boolean(footer && footer.bottom <= innerHeight && footer.top >= 0), + pieceScrolls: Boolean(pieceScroll && pieceScroll.scrollHeight > pieceScroll.clientHeight), + railScrolls: Boolean(railScroll && railScroll.scrollHeight > railScroll.clientHeight) + }; + }); + expect(compactLayout).toEqual({ + pageFits: true, + twoColumns: true, + footerVisible: true, + pieceScrolls: true, + railScrolls: true + }); + await page.screenshot({ path: testInfo.outputPath('live-controller-compact-desktop.png') }); + + const countdownBeforeReload = await page.locator('.section-countdown strong').textContent(); + await page.reload(); + await expect(page.locator('#current-title')).toHaveText( + 'Warum praktische Effekte wieder auffallen' + ); + const countdownAfterReload = await page.locator('.section-countdown strong').textContent(); + expect(countdownAfterReload).not.toBe('05:00'); + expect(countdownAfterReload).toMatch(/0[34]:\d{2}/); + expect(countdownBeforeReload).toMatch(/0[34]:\d{2}/); + + await page.setViewportSize({ width: 390, height: 700 }); + const liveRail = page.locator('.live-rail'); + const railClose = liveRail.getByRole('button', { name: 'Ablauf schließen' }); + await expect(liveRail).toBeHidden(); + await expect(page.locator('.live-action-bar')).toBeVisible(); + await expectNoHorizontalOverflow(page); + expect( + await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight) + ).toBe(true); + await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') }); + await page.getByRole('button', { name: /Ablauf · Piece/ }).click(); + await expect(liveRail).toHaveClass(/open/); + await expect(railClose).toBeFocused(); + await expect + .poll(async () => { + const box = await liveRail.boundingBox(); + return box ? Math.ceil(box.x + box.width) : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(390); + const mobileRailBox = await liveRail.boundingBox(); + expect(mobileRailBox?.width ?? 0).toBeGreaterThan(340); + await expect(page.locator('.live-run-item.current')).toBeInViewport(); + await page.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') }); + await page.keyboard.press('Escape'); + await expect(liveRail).toBeHidden(); + await expect(page.getByRole('button', { name: /Ablauf · Piece/ })).toBeFocused(); + await page.getByRole('button', { name: /Ablauf · Piece/ }).click(); + await page.setViewportSize({ width: 900, height: 450 }); + await expect(liveRail).not.toHaveAttribute('role', 'dialog'); + await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', ''); + await page.setViewportSize({ width: 390, height: 700 }); + await expect(liveRail).toBeHidden(); + + await page.getByRole('button', { name: /Ablauf · Piece/ }).click(); + await liveRail + .getByRole('button', { + name: 'Die kleine Konsole mit dem großen Dreh als aktuelles Piece auswählen' + }) + .click(); + await expect(liveRail).toBeHidden(); + await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); + await page.getByRole('button', { name: /Ablauf · Piece/ }).click(); + await liveRail + .getByRole('button', { + name: 'Warum praktische Effekte wieder auffallen als aktuelles Piece auswählen' + }) + .click(); + await expect(liveRail).toBeHidden(); + await expect(page.locator('#current-title')).toHaveText( + 'Warum praktische Effekte wieder auffallen' + ); + + await page.getByRole('button', { name: 'Erledigen' }).click(); + await expect(page.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toBeVisible(); + await page.getByRole('button', { name: 'Live-Stand wiederaufnehmen' }).click(); + await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); + await expectNoHorizontalOverflow(page); + await page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click(); + await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible(); + await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen'); + await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0); + await output.route('**/state', (route) => route.abort()); await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 }); await output.unroute('**/state'); await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 }); - await page.setViewportSize({ width: 390, height: 844 }); - await expectNoHorizontalOverflow(page); - await page.screenshot({ - path: testInfo.outputPath('live-controller-mobile.png'), - fullPage: true - }); + await page.getByRole('button', { name: 'Präsentation leeren' }).click(); + await expect(output.locator('.output-content')).toHaveCount(0); + await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/); await page.setViewportSize({ width: 1280, height: 900 }); await page.evaluate(() => { window.open = () => null; }); + await page.getByText('Menü', { exact: true }).click(); await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click(); await expect( page