1 Commits
Author SHA1 Message Date
hnzio 7a49e66c37 Release 0.1.1 2026-10-21 16:38:56 +02:00
16 changed files with 603 additions and 361 deletions

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@
"title": "Sendewerk",
"description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.",
"tagline": "Vom Fundstück bis zur Livesendung",
"version": "0.1.0",
"version": "0.1.1",
"healthCheckPath": "/healthz",
"httpPort": 3000,
"addons": {
+6 -3
View File
@@ -3,7 +3,7 @@
## Etappe 1
| Kriterium | Nachweis | Stand |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Frischer Checkout startet ohne Cloudron | `npm ci` erfolgreich; README-Setup und lokaler Produktionsstart ausgeführt | erfüllt am 21.10.2026 |
| Typprüfung und Build erfolgreich | `npm run check`, `npm run build` | erfüllt am 21.10.2026 |
| Theme bleibt nach Neuladen erhalten | Playwright: System auf Hell wechseln, Reload, DOM und Schalterzustand prüfen | erfüllt am 21.10.2026 |
@@ -11,7 +11,8 @@
| Migration wiederholt ausführbar | lokal mehrfach ausgeführt; Verhaltenstest führt Migration zweimal in temporärer DB aus | erfüllt am 21.10.2026 |
| Seed reproduzierbar | lokal und im Verhaltenstest zweimal ausgeführt; danach 4 Pieces, 1 Sendung und 3 Verwendungen | erfüllt am 21.10.2026 |
| Tests verwenden getrennte Daten | Vitest und Playwright erzeugen je eigene Datenbank im Betriebssystem-Tempdir | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 4 Playwright-Abläufe; 1440x1000, 1280x900 und 390x844; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Desktop und Smartphone ohne horizontales Scrollen | 8 Playwright-Abläufe bei 390, 768, 960, 1280 und 1440 Pixeln; Screenshots visuell kontrolliert | erfüllt am 21.10.2026 |
| Kompakte Arbeitsoberfläche in beiden Farbmodi | 390, 768 und 1440 Pixel jeweils in Light/Dark; einspaltige Mobilkarten, lange Titel, Metadatenumbrüche und Leerzustände | erfüllt am 21.10.2026 |
| Cloudron-Pfade und Healthcheck vorbereitet | Manifestpfad `/app/data/sendewerk.sqlite`, `/healthz`, `start.sh`; JSON-Kernfelder und Bash-Syntax geprüft | vorbereitet; Container offen |
| Echte Cloudron-Prüfung | Installation, Backup/Restore, Proxy und read-only Laufzeit | offen, kein Serverzugang |
@@ -23,7 +24,9 @@
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- `npm run test:e2e`: 4 von 4 Browsertests erfolgreich
- `npm run test:e2e`: 8 von 8 Browsertests erfolgreich; 390, 768 und 1440 Pixel
jeweils in Light- und Darkmode sowie Tastaturbedienung, feste Navigation,
einspaltige Funduskarten, lange Inhalte und Überläufe geprüft
- Zwei getrennte `npm start`-Läufe: Healthcheck und persistente Daten erfolgreich
- `bash -n start.sh` und programmatische Manifest-Kernfeldprüfung: erfolgreich
+6 -2
View File
@@ -66,8 +66,12 @@ getrennt.
## Oberflächenstruktur
Die Hauptnavigation umfasst Eingang, Fundus, Sendungen, Archiv und Verwaltung.
Auf breiten Ansichten stehen Fundus und Ablauf nebeneinander. Auf schmalen
Ansichten werden sie über eine gut erreichbare Umschaltung einzeln gezeigt.
Auf dem Desktop bleibt eine feste Seitenleiste sichtbar; Verwaltung liegt an
ihrem unteren Ende. Bis einschließlich `52rem` ersetzen eine obere Leiste und
die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Der Live-Modus ist eine eigene, fokussierte Ansicht; die spätere
Präsentationsausgabe erhält eine separate, strikt reduzierte Datenansicht.
+13
View File
@@ -81,3 +81,16 @@ Vorschauseiten lesen echte persistente SQLite-Seed-Daten, bieten aber keine
funktionslosen Speichern- oder Live-Schaltflächen. Ein sichtbarer Hinweis
grenzt Gestaltungsvorschauen von den späteren Fachfunktionen ab. Damit wird
keine noch nicht implementierte Funktion als fertig dargestellt.
## D-007: Arbeitsansicht nach verfügbarem Platz
Die feste Desktop-Seitenleiste wird bis `52rem` verwendet und dann vollständig
durch mobile Kopf- und Fußnavigation ersetzt. Der Sendungsbau schaltet bereits
bis `72rem` von zwei Spalten auf eine bedienbare Pane-Umschaltung um. Dadurch
bleibt die Desktopnavigation in schmalen Fenstern erhalten, ohne Fundus und
Ablauf zusammenzudrücken.
Der Fundus erscheint auf dem Desktop als kompakte Liste und auf kleinen
Bildschirmen als Kartenfolge. Vorschau- und Schreibschutzinformationen sind in
einem nativen, tastaturbedienbaren `details`-Element gebündelt. Diese
Darstellungsentscheidung ändert weder Datenmodell noch Funktionsumfang.
+14
View File
@@ -13,6 +13,8 @@ lokaler Betriebsweg sind dokumentiert. Etappe 2 wurde nicht begonnen.
- persistentes Light-/Dark-/System-Theme
- responsive Navigation und gekennzeichnete Vorschauen für Fundus,
Sendungsbau und Live-Modus
- kompakte Desktop-Arbeitsansicht mit fester Seitenleiste, Fundusliste und
zweigeteiltem Sendungsbau; auf schmalen Ansichten Umschaltung statt Engpass
- Healthcheck, Produktionsstart und frühes Cloudron-Paket
- getrennte Unit-/Integrations- und Browser-Testdaten
@@ -20,6 +22,18 @@ Die Vorschauen lesen echte Daten aus SQLite. Suche und mobile Pane-Umschaltung
funktionieren lokal; redaktionelle Schreiboperationen bleiben bewusst den
späteren Etappen vorbehalten.
Die abschließende Oberflächenüberarbeitung von Etappe 1 reduziert große
Seitenköpfe und verschachtelte Karten, bündelt den Schreibschutzhinweis
aufklappbar und hält Entwicklungsinformationen aus der Arbeitsoberfläche heraus.
Eingang und Archiv zeigen kurze Leerzustände, die Verwaltung trennt
Abschnittskategorien, Formate und Themen kompakt.
Die abschließende responsive Prüfung verdichtet Seitenleiste, Navigation und
Funduszeilen weiter. Mobile Funduskarten sind intern einspaltig; lange Titel,
Beschreibungen und Metadaten bleiben ohne feste Kartenhöhe sichtbar. Suche und
Statusfilter sind gleich hoch, und die Filtergruppe wird nur als Ganzes
umgebrochen.
## Nicht Bestandteil dieser Etappe
Anmeldung, Rollen, echte Inhaltsbearbeitung, Autospeichern, Sendungsplanung,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sendewerk",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sendewerk",
"version": "0.1.0",
"version": "0.1.1",
"dependencies": {
"better-sqlite3": "^13.0.3"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sendewerk",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"engines": {
"node": ">=22.17 <25"
+319 -239
View File
@@ -25,7 +25,7 @@
--gold: #c99530;
--danger: #a94a43;
--shadow: 0 18px 45px rgba(56, 52, 44, 0.08);
--sidebar-width: 15rem;
--sidebar-width: 14rem;
--radius-large: 1.5rem;
--radius-medium: 1rem;
--radius-small: 0.7rem;
@@ -124,10 +124,11 @@ a {
display: flex;
width: var(--sidebar-width);
flex-direction: column;
padding: 1.5rem 1rem 1.1rem;
padding: 1.25rem 0.85rem 1rem;
border-right: 1px solid var(--line);
background: color-mix(in srgb, var(--bg-elevated) 90%, transparent);
backdrop-filter: blur(24px) saturate(1.3);
overflow-y: auto;
}
.brand {
@@ -174,15 +175,16 @@ a {
.main-navigation {
display: grid;
gap: 0.28rem;
margin-top: 2.25rem;
margin-top: 1.5rem;
}
.main-navigation a {
.main-navigation a,
.utility-navigation a {
display: flex;
align-items: center;
gap: 0.82rem;
min-height: 2.85rem;
padding: 0.65rem 0.8rem;
min-height: 2.65rem;
padding: 0.45rem 0.7rem;
border-radius: 0.8rem;
color: var(--text-muted);
font-weight: 590;
@@ -192,12 +194,14 @@ a {
transform 140ms ease;
}
.main-navigation a:hover {
.main-navigation a:hover,
.utility-navigation a:hover {
background: color-mix(in srgb, var(--panel-strong) 66%, transparent);
color: var(--text);
}
.main-navigation a.active {
.main-navigation a.active,
.utility-navigation a.active {
background: var(--panel-strong);
box-shadow: 0 7px 20px rgba(48, 48, 43, 0.07);
color: var(--accent);
@@ -209,6 +213,12 @@ a {
margin-top: auto;
}
.utility-navigation {
display: grid;
padding-top: 0.85rem;
border-top: 1px solid var(--line);
}
.theme-toggle {
display: flex;
width: 100%;
@@ -240,33 +250,6 @@ a {
line-height: 1;
}
.stage-indicator {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.72rem;
color: var(--text-muted);
}
.stage-indicator > span {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: #58a66b;
box-shadow: 0 0 0 0.25rem color-mix(in srgb, #58a66b 14%, transparent);
}
.stage-indicator div {
display: grid;
}
.stage-indicator strong {
color: var(--text);
font-size: 0.75rem;
}
.stage-indicator small {
font-size: 0.69rem;
}
.mobile-header,
.mobile-navigation {
display: none;
@@ -280,7 +263,7 @@ a {
.page-wrap {
width: min(100%, 92rem);
margin: 0 auto;
padding: 3.4rem clamp(2rem, 4vw, 4.6rem) 5rem;
padding: 2rem clamp(1.5rem, 3vw, 3.25rem) 3rem;
}
.narrow-page {
@@ -288,17 +271,16 @@ a {
}
.page-heading {
margin-bottom: 1.75rem;
margin-bottom: 1rem;
}
.page-heading h1,
.live-header h1 {
max-width: 18ch;
margin: 0.22rem 0 0;
font-size: clamp(2rem, 4vw, 3.65rem);
.page-heading h1 {
max-width: 24ch;
margin: 0;
font-size: clamp(1.7rem, 2.4vw, 2.3rem);
font-weight: 690;
letter-spacing: -0.055em;
line-height: 1.02;
letter-spacing: -0.04em;
line-height: 1.08;
}
.eyebrow {
@@ -312,76 +294,98 @@ a {
.page-intro {
max-width: 42rem;
margin: 0.9rem 0 0;
margin: 0.35rem 0 0;
color: var(--text-muted);
font-size: 1.04rem;
font-size: 0.88rem;
}
.split-heading,
.show-heading,
.live-header {
.show-heading {
display: flex;
align-items: flex-end;
align-items: center;
justify-content: space-between;
gap: 2rem;
gap: 1.25rem;
}
.heading-stat {
display: grid;
min-width: 6.5rem;
padding: 0.8rem 1rem;
border-left: 1px solid var(--line-strong);
display: flex;
align-items: baseline;
gap: 0.38rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--panel);
}
.heading-stat strong {
font-size: 1.7rem;
font-size: 1rem;
line-height: 1;
}
.heading-stat span {
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.78rem;
font-size: 0.7rem;
}
.preview-notice {
display: flex;
align-items: flex-start;
gap: 0.8rem;
margin: 0 0 1.3rem;
padding: 0.85rem 1rem;
margin: 0 0 0.85rem;
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
border-radius: var(--radius-small);
background: color-mix(in srgb, var(--accent-soft) 45%, transparent);
color: var(--accent-text);
}
.preview-notice strong {
font-size: 0.79rem;
.preview-notice summary {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 2.35rem;
padding: 0.45rem 0.7rem;
cursor: pointer;
list-style: none;
}
.preview-notice summary::-webkit-details-marker {
display: none;
}
.preview-notice strong {
font-size: 0.72rem;
}
.preview-notice p {
margin: 0.08rem 0 0;
margin: 0;
padding: 0 0.7rem 0.65rem 1.8rem;
color: var(--text-muted);
font-size: 0.76rem;
font-size: 0.72rem;
}
.preview-dot {
flex: 0 0 auto;
width: 0.55rem;
height: 0.55rem;
margin-top: 0.38rem;
width: 0.48rem;
height: 0.48rem;
border: 2px solid var(--accent);
border-radius: 50%;
}
.preview-chevron {
margin-left: auto;
color: var(--text-muted);
font-size: 0.85rem;
transition: transform 140ms ease;
}
.preview-notice[open] .preview-chevron {
transform: rotate(180deg);
}
.library-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
margin-bottom: 0.75rem;
}
.search-field,
.compact-search {
.search-field {
display: flex;
align-items: center;
gap: 0.6rem;
@@ -391,22 +395,20 @@ a {
}
.search-field {
width: min(100%, 28rem);
height: 2.85rem;
width: min(100%, 24rem);
height: 2.5rem;
padding: 0 0.9rem;
border-radius: 0.88rem;
box-shadow: 0 6px 20px rgba(48, 48, 43, 0.035);
}
.search-field span:first-child,
.compact-search span:first-child {
.search-field span:first-child {
font-size: 1.35rem;
line-height: 1;
transform: rotate(-15deg);
}
.search-field input,
.compact-search input {
.search-field input {
width: 100%;
border: 0;
outline: 0;
@@ -414,8 +416,7 @@ a {
color: var(--text);
}
.search-field input::placeholder,
.compact-search input::placeholder {
.search-field input::placeholder {
color: var(--text-faint);
}
@@ -427,6 +428,10 @@ a {
background: var(--panel-soft);
}
.filter-tabs {
height: 2.5rem;
}
.filter-tabs button,
.mobile-pane-switch button {
padding: 0.48rem 0.82rem;
@@ -439,6 +444,12 @@ a {
font-weight: 640;
}
.filter-tabs button {
height: 2rem;
padding-block: 0.35rem;
white-space: nowrap;
}
.filter-tabs button.active,
.mobile-pane-switch button.active {
background: var(--panel-strong);
@@ -448,33 +459,30 @@ a {
.piece-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
gap: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.piece-card {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(8.5rem, 0.9fr) minmax(12rem, auto);
align-items: center;
gap: 1rem;
min-width: 0;
min-height: 17rem;
flex-direction: column;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: var(--radius-large);
background: var(--panel);
box-shadow: 0 8px 25px rgba(55, 52, 46, 0.035);
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
transition:
border-color 150ms ease,
transform 150ms ease,
box-shadow 150ms ease;
background-color 150ms ease,
border-color 150ms ease;
}
.piece-card:hover {
transform: translateY(-2px);
border-color: var(--line-strong);
box-shadow: var(--shadow);
.piece-card:last-child {
border-bottom: 0;
}
.piece-card-topline,
.source-card > div,
.live-kicker {
display: flex;
@@ -517,25 +525,37 @@ a {
color: var(--accent-text);
}
.piece-card h2 {
margin: 1.35rem 0 0.55rem;
font-size: 1.35rem;
font-weight: 670;
letter-spacing: -0.025em;
line-height: 1.18;
.piece-primary {
min-width: 0;
}
.piece-card > p {
.piece-primary h2 {
margin: 0 0 0.2rem;
font-size: 0.98rem;
font-weight: 670;
letter-spacing: -0.015em;
line-height: 1.25;
overflow-wrap: anywhere;
}
.piece-primary p {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
font-size: 0.76rem;
line-height: 1.4;
}
.piece-classification {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.piece-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 1.1rem;
margin: 0;
}
.piece-tags span {
@@ -546,25 +566,30 @@ a {
font-size: 0.67rem;
}
.piece-card footer {
.piece-state {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: auto;
padding-top: 1.1rem;
border-top: 1px solid var(--line);
align-items: flex-end;
flex-direction: column;
gap: 0.3rem;
color: var(--text-faint);
font-size: 0.72rem;
font-size: 0.66rem;
}
.piece-card footer b {
.piece-facts {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.7rem;
}
.piece-state b {
color: var(--text);
font-weight: 680;
}
.empty-state,
.planned-panel {
padding: clamp(2rem, 7vw, 5rem);
.empty-state {
padding: 1.5rem;
text-align: center;
}
@@ -575,13 +600,16 @@ a {
.empty-state > span {
color: var(--text-faint);
font-size: 2rem;
font-size: 1.4rem;
}
.empty-state h2 {
margin-bottom: 0.3rem;
margin: 0.35rem 0 0.2rem;
font-size: 1rem;
}
.empty-state p {
margin: 0 0 0.8rem;
color: var(--text-muted);
font-size: 0.78rem;
}
.empty-state button {
padding: 0.65rem 0.9rem;
@@ -595,14 +623,14 @@ a {
.show-summary {
display: flex;
align-items: stretch;
gap: 0.65rem;
align-items: center;
gap: 0.5rem;
}
.show-summary > div {
display: grid;
min-width: 5.8rem;
padding: 0.6rem 0.85rem;
padding: 0.35rem 0.7rem;
border-left: 1px solid var(--line-strong);
}
@@ -618,7 +646,8 @@ a {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.7rem 0.9rem;
min-height: 2.45rem;
padding: 0.55rem 0.8rem;
border-radius: 0.72rem;
background: var(--text);
color: var(--bg);
@@ -631,22 +660,39 @@ a {
font-size: 1rem;
}
.show-heading-copy {
min-width: 0;
}
.show-title {
margin: 0.3rem 0 0;
font-size: 0.9rem;
font-weight: 680;
overflow-wrap: anywhere;
}
.show-date {
margin: 0.08rem 0 0;
color: var(--text-muted);
font-size: 0.72rem;
}
.mobile-pane-switch {
display: none;
}
.builder-workspace {
display: grid;
grid-template-columns: minmax(18rem, 0.78fr) minmax(28rem, 1.35fr);
gap: 1rem;
grid-template-columns: minmax(15rem, 0.72fr) minmax(24rem, 1.45fr);
gap: 0.75rem;
align-items: start;
}
.builder-pane {
min-width: 0;
padding: 1.15rem;
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-large);
border-radius: var(--radius-medium);
background: var(--panel);
}
@@ -655,7 +701,7 @@ a {
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
margin-bottom: 0.65rem;
}
.pane-heading h2 {
@@ -672,27 +718,15 @@ a {
font-weight: 680;
}
.compact-search {
height: 2.5rem;
padding: 0 0.75rem;
border-radius: 0.72rem;
}
.compact-search:has(input:disabled) {
opacity: 0.72;
}
.source-list {
display: grid;
gap: 0.55rem;
margin-top: 0.8rem;
gap: 0;
border-top: 1px solid var(--line);
}
.source-card {
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
background: var(--panel-strong);
padding: 0.72rem 0.2rem;
border-bottom: 1px solid var(--line);
}
.source-card > div > span:last-child {
@@ -700,9 +734,10 @@ a {
font-size: 0.68rem;
}
.source-card h3 {
margin: 0.72rem 0 0.2rem;
margin: 0.42rem 0 0.12rem;
font-size: 0.88rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.source-card p {
margin: 0;
@@ -711,8 +746,8 @@ a {
}
.reserve-box {
margin-top: 0.8rem;
padding: 0.9rem;
margin-top: 0.65rem;
padding: 0.7rem;
border: 1px dashed color-mix(in srgb, var(--gold) 48%, var(--line));
border-radius: 0.9rem;
background: color-mix(in srgb, var(--gold) 7%, transparent);
@@ -745,14 +780,13 @@ a {
.run-list {
display: grid;
gap: 0.7rem;
gap: 0;
border-top: 1px solid var(--line);
}
.run-section {
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel-strong);
padding: 0.78rem 0.2rem;
border-bottom: 1px solid var(--line);
}
.run-section > header {
@@ -770,6 +804,7 @@ a {
margin: 0.13rem 0 0;
font-size: 0.95rem;
letter-spacing: -0.012em;
overflow-wrap: anywhere;
}
.section-number {
@@ -787,32 +822,26 @@ a {
.usage-list {
display: grid;
gap: 0.42rem;
margin: 0.8rem 0 0 2.8rem;
margin: 0.55rem 0 0 2.8rem;
}
.usage-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
padding: 0.68rem;
padding: 0.55rem 0.65rem;
border-radius: 0.72rem;
background: var(--panel-soft);
}
.drag-hint {
color: var(--text-faint);
font-size: 1.05rem;
}
.usage-row div {
display: grid;
min-width: 0;
}
.usage-row strong {
overflow: hidden;
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.usage-row small {
color: var(--text-muted);
@@ -830,8 +859,8 @@ a {
color: var(--accent-text);
}
.section-placeholder {
margin: 0.75rem 0 0 2.8rem;
padding: 0.65rem;
margin: 0.5rem 0 0 2.8rem;
padding: 0.5rem 0.65rem;
border: 1px dashed var(--line-strong);
border-radius: 0.7rem;
color: var(--text-muted);
@@ -842,11 +871,18 @@ a {
max-width: 88rem;
}
.live-header {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
margin-bottom: 1rem;
}
.live-header h1 {
max-width: 24ch;
font-size: clamp(1.65rem, 3vw, 2.65rem);
margin: 0.18rem 0 0;
font-size: clamp(1.5rem, 2.5vw, 2.1rem);
letter-spacing: -0.035em;
line-height: 1.08;
}
.live-pulse {
@@ -899,7 +935,7 @@ a {
.live-current {
min-width: 0;
padding: clamp(1.4rem, 4vw, 3rem);
padding: clamp(1.25rem, 3vw, 2.25rem);
border: 1px solid var(--line);
border-radius: 1.8rem;
background: var(--panel);
@@ -918,8 +954,8 @@ a {
}
.live-current h2 {
max-width: 20ch;
margin: 1.4rem 0 2rem;
font-size: clamp(2rem, 4vw, 3.4rem);
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;
@@ -933,7 +969,7 @@ a {
.moderation-card,
.transition-card {
margin-top: 2rem;
margin-top: 1.25rem;
padding: 1rem 1.1rem;
border-radius: 0.9rem;
background: var(--panel-soft);
@@ -1070,43 +1106,49 @@ a {
}
.planned-panel {
margin-top: 2.5rem;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 0.85rem;
align-items: center;
margin-top: 1.25rem;
padding: 1rem;
text-align: left;
}
.planned-panel h2 {
margin: 0.55rem 0;
font-size: 1.55rem;
letter-spacing: -0.03em;
margin: 0;
font-size: 0.98rem;
letter-spacing: -0.015em;
}
.planned-panel > p:last-child {
max-width: 40rem;
margin: 0 auto;
grid-column: 2;
margin: 0.18rem 0 0;
color: var(--text-muted);
font-size: 0.78rem;
}
.planned-icon {
display: grid;
width: 3.2rem;
height: 3.2rem;
margin: 0 auto 1rem;
grid-row: 1 / span 2;
width: 2.35rem;
height: 2.35rem;
margin: 0;
place-items: center;
border-radius: 1rem;
border-radius: 0.7rem;
background: var(--accent-soft);
color: var(--accent);
font-size: 1.6rem;
font-size: 1.15rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.taxonomy-panel,
.planned-settings {
padding: 1.3rem;
.taxonomy-panel {
padding: 1rem;
}
.taxonomy-panel h2,
.planned-settings h2 {
margin: 0.25rem 0 1.1rem;
font-size: 1.08rem;
.taxonomy-panel h2 {
margin: 0.2rem 0 0.7rem;
font-size: 0.95rem;
}
.taxonomy-list {
display: flex;
@@ -1117,11 +1159,13 @@ a {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.42rem 0.68rem;
min-width: 0;
padding: 0.32rem 0.55rem;
border: 1px solid var(--line);
border-radius: 2rem;
color: var(--text-muted);
font-size: 0.75rem;
font-size: 0.7rem;
overflow-wrap: anywhere;
}
.taxonomy-list i {
width: 0.48rem;
@@ -1129,27 +1173,41 @@ a {
border-radius: 50%;
background: var(--tax-color);
}
.planned-settings {
background: color-mix(in srgb, var(--panel) 72%, var(--accent-soft));
}
.planned-settings p:last-child {
.settings-footnote {
margin: 0.75rem 0 0;
color: var(--text-muted);
font-size: 0.82rem;
font-size: 0.72rem;
}
@media (min-width: 84rem) {
.piece-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 70rem) {
.builder-workspace {
grid-template-columns: minmax(16rem, 0.72fr) minmax(24rem, 1.2fr);
}
@media (max-width: 72rem) {
.page-wrap {
padding-inline: 2rem;
}
.mobile-pane-switch {
display: flex;
width: 100%;
margin-bottom: 0.65rem;
}
.mobile-pane-switch button {
flex: 1;
}
.builder-workspace {
display: block;
}
.builder-pane.mobile-hidden {
display: none;
}
}
@media (max-width: 64rem) and (min-width: 52.001rem) {
.library-toolbar {
align-items: stretch;
flex-direction: column;
}
.library-toolbar .filter-tabs {
align-self: flex-start;
}
}
@media (max-width: 52rem) {
@@ -1211,6 +1269,7 @@ a {
font-weight: 620;
}
.mobile-navigation a.active {
background: var(--accent-soft);
color: var(--accent);
}
.mobile-navigation a svg {
@@ -1219,24 +1278,20 @@ a {
}
#main-content {
margin-left: 0;
padding: var(--mobile-header) 0 var(--mobile-nav);
padding: var(--mobile-header) 0 calc(var(--mobile-nav) + env(safe-area-inset-bottom) + 1rem);
}
.page-wrap {
width: 100%;
padding: 1.6rem 1rem 3rem;
padding: 1.25rem 1rem 2rem;
}
.page-heading h1 {
font-size: clamp(2rem, 10vw, 3rem);
font-size: 1.75rem;
}
.split-heading,
.show-heading,
.live-header {
align-items: flex-start;
flex-direction: column;
gap: 1.15rem;
}
.heading-stat {
display: none;
gap: 0.75rem;
}
.library-toolbar {
align-items: stretch;
@@ -1253,10 +1308,50 @@ a {
flex: 1 0 auto;
}
.piece-grid {
grid-template-columns: 1fr;
gap: 0.7rem;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.piece-card {
min-height: 15.5rem;
display: flex;
align-items: stretch;
flex-direction: column;
gap: 0.7rem;
grid-template-columns: none;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--panel);
}
.piece-card:last-child {
border-bottom: 1px solid var(--line);
}
.piece-classification {
gap: 0.55rem;
}
.piece-primary,
.piece-classification,
.piece-state {
min-width: 0;
width: 100%;
}
.piece-primary h2 {
font-size: 1.08rem;
}
.piece-state {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
gap: 0.5rem 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid var(--line);
}
.piece-facts {
justify-content: flex-start;
}
.show-summary {
width: 100%;
@@ -1269,20 +1364,6 @@ a {
flex: 1 0 100%;
justify-content: center;
}
.mobile-pane-switch {
display: flex;
width: 100%;
margin-bottom: 0.8rem;
}
.mobile-pane-switch button {
flex: 1;
}
.builder-workspace {
display: block;
}
.builder-pane.mobile-hidden {
display: none;
}
.builder-pane {
padding: 0.85rem;
}
@@ -1293,9 +1374,6 @@ a {
.usage-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.drag-hint {
display: none;
}
.live-header-actions {
width: 100%;
justify-content: space-between;
@@ -1310,6 +1388,11 @@ a {
.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;
}
@@ -1322,10 +1405,7 @@ a {
.page-wrap {
padding-inline: 0.75rem;
}
.preview-notice {
padding: 0.75rem;
}
.piece-card footer {
.piece-state {
align-items: flex-start;
flex-direction: column;
gap: 0.25rem;
+7 -6
View File
@@ -2,12 +2,13 @@
let { area }: { area: string } = $props();
</script>
<aside class="preview-notice" aria-label="Hinweis zur Gestaltungsvorschau">
<details class="preview-notice">
<summary>
<span class="preview-dot" aria-hidden="true"></span>
<div>
<strong>Gestaltungsvorschau: {area}</strong>
<strong>Vorschau · {area}</strong>
<span class="preview-chevron" aria-hidden="true">⌄</span>
</summary>
<p>
In Etappe 1 schreibgeschützt. Die gezeigten Inhalte stammen aus der lokalen SQLite-Datenbank.
Diese Ansicht ist schreibgeschützt. Bearbeiten und Speichern folgen in einer späteren Etappe.
</p>
</div>
</aside>
</details>
+16 -9
View File
@@ -6,13 +6,14 @@
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
let { children } = $props();
const navigation = [
const primaryNavigation = [
{ href: '/eingang', label: 'Eingang', icon: 'inbox' },
{ href: '/fundus', label: 'Fundus', icon: 'library' },
{ href: '/sendungen', label: 'Sendungen', icon: 'shows' },
{ href: '/archiv', label: 'Archiv', icon: 'archive' },
{ href: '/verwaltung', label: 'Verwaltung', icon: 'settings' }
{ href: '/archiv', label: 'Archiv', icon: 'archive' }
] as const;
const administration = { href: '/verwaltung', label: 'Verwaltung', icon: 'settings' } as const;
const mobileNavigation = [...primaryNavigation, administration];
function isActive(href: string) {
return (
@@ -36,7 +37,7 @@
</a>
<nav class="main-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
{#each primaryNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
@@ -49,11 +50,17 @@
</nav>
<div class="sidebar-footer">
<nav class="utility-navigation" aria-label="Verwaltung">
<a
href={resolve(administration.href)}
class:active={isActive(administration.href)}
aria-current={isActive(administration.href) ? 'page' : undefined}
>
<NavIcon name={administration.icon} />
<span>{administration.label}</span>
</a>
</nav>
<ThemeToggle />
<div class="stage-indicator">
<span></span>
<div><strong>Etappe 1</strong><small>Lokaler Entwurf</small></div>
</div>
</div>
</aside>
@@ -68,7 +75,7 @@
<main id="main-content">{@render children()}</main>
<nav class="mobile-navigation" aria-label="Hauptnavigation">
{#each navigation as item (item.href)}
{#each mobileNavigation as item (item.href)}
<a
href={resolve(item.href)}
class:active={isActive(item.href)}
+3 -10
View File
@@ -2,19 +2,12 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Bewahrte Sendungen</p>
<h1>Archiv</h1>
<p class="page-intro">
Abgeschlossene Abläufe bleiben hier in ihrem damaligen Stand nachvollziehbar.
</p>
<p class="page-intro">Abgeschlossene Sendungen in ihrem damaligen Stand.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">□</div>
<p class="eyebrow">Geplant für Etappe 6</p>
<h2>Historie braucht klare Regeln.</h2>
<p>
Stabile Snapshots, Verwendungshistorie und bewusste Archivierung werden gemeinsam
implementiert, statt jetzt nur eine leere Liste vorzutäuschen.
</p>
<h2>Noch keine Sendung archiviert</h2>
<p>Abgeschlossene Sendungen werden hier später dauerhaft auffindbar.</p>
</section>
</div>
+3 -11
View File
@@ -2,20 +2,12 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">Sammelstelle</p>
<h1>Eingang</h1>
<p class="page-intro">
Neue Gedanken und spätere RSS-Fundstücke landen hier, bevor sie in den Fundus übernommen
werden.
</p>
<p class="page-intro">Neue Gedanken und Fundstücke vor der Übernahme in den Fundus.</p>
</header>
<section class="quiet-panel planned-panel">
<div class="planned-icon" aria-hidden="true">+</div>
<p class="eyebrow">Geplant für Etappe 3 und 7</p>
<h2>Noch ist der Tresen leer.</h2>
<p>
Schnellaufnahme, Feed-Vorschau und bewusste Übernahme werden erst mit den dazugehörigen
Speicher- und Sicherheitsregeln umgesetzt.
</p>
<h2>Keine neuen Inhalte</h2>
<p>Hier erscheinen später neue Gedanken und ausgewählte Feed-Fundstücke.</p>
</section>
</div>
+14 -13
View File
@@ -27,11 +27,8 @@
<div class="page-wrap">
<header class="page-heading split-heading">
<div>
<p class="eyebrow">Redaktioneller Fundus</p>
<h1>Gute Stücke, sofort griffbereit.</h1>
<p class="page-intro">
Vorbereitete Ideen, Gespräche und Fundstücke für die nächste Sendung.
</p>
<h1>Fundus</h1>
<p class="page-intro">Vorbereitete Inhalte für kommende Sendungen.</p>
</div>
<div class="heading-stat" aria-label={`${data.pieces.length} Inhalte im Beispieldatensatz`}>
<strong>{data.pieces.length}</strong><span>Inhalte</span>
@@ -65,22 +62,26 @@
<div class="piece-grid">
{#each filteredPieces as piece (piece.id)}
<article class="piece-card" style={`--category: ${piece.categoryColor}`}>
<div class="piece-card-topline">
<span class="category-label"><i></i>{piece.category}</span>
<span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span
>
</div>
<div class="piece-primary">
<h2>{piece.title}</h2>
<p>{piece.summary}</p>
</div>
<div class="piece-classification">
<span class="category-label"><i></i>{piece.category}</span>
<div class="piece-tags">
<span>{piece.format}</span>
{#each piece.topics as topic (topic)}<span>{topic}</span>{/each}
</div>
<footer>
</div>
<div class="piece-state">
<span class:ready={piece.status === 'ready'} class="status-label"
>{statusLabel[piece.status]}</span
>
<span class="piece-facts">
<span><b>{formatClock(piece.durationSeconds)}</b> geplant</span>
<span>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span>
</footer>
</span>
</div>
</article>
{/each}
</div>
+6 -17
View File
@@ -18,9 +18,10 @@
<div class="page-wrap builder-page">
<header class="page-heading show-heading">
<div>
<p class="eyebrow">Nächste Sendung · {data.show.startsAtBerlin} Uhr</p>
<h1>{data.show.title}</h1>
<div class="show-heading-copy">
<h1>Sendungen</h1>
<p class="show-title">{data.show.title}</p>
<p class="show-date">{data.show.startsAtBerlin} Uhr</p>
</div>
<div class="show-summary">
<div><strong>{plannedCount}</strong><span>Stücke</span></div>
@@ -57,15 +58,6 @@
</div>
<span>{data.pieces.filter((piece) => piece.status === 'ready').length}</span>
</header>
<label class="compact-search">
<span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span>
<input
type="search"
placeholder="Fundus durchsuchen"
disabled
title="Die vollständige Suche folgt in Etappe 3"
/>
</label>
<div class="source-list">
{#each data.pieces.filter((piece) => piece.status === 'ready') as piece (piece.id)}
<article class="source-card" style={`--category: ${piece.categoryColor}`}>
@@ -116,10 +108,9 @@
<div class="usage-list">
{#each section.usages.filter((usage) => !usage.isReserve) as usage (usage.id)}
<div class="usage-row">
<span class="drag-hint" aria-hidden="true">⠿</span>
<div>
<strong>{usage.title}</strong><small
>{formatClock(usage.durationSeconds)} · Eigene Sendungskopie</small
>{formatClock(usage.durationSeconds)} · Stand für diese Sendung</small
>
</div>
<span class:current={usage.status === 'current'} class="usage-status"
@@ -129,9 +120,7 @@
{/each}
</div>
{:else}
<div class="section-placeholder">
Eigenständiger Spieleblock · keine Piece-Kopie nötig
</div>
<div class="section-placeholder">Spieleblock ohne vorbereiteten Fundusinhalt</div>
{/if}
</article>
{/each}
+2 -13
View File
@@ -7,12 +7,8 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<p class="eyebrow">System & Struktur</p>
<h1>Verwaltung</h1>
<p class="page-intro">
Die bereits angelegte Taxonomie ist sichtbar; Bearbeitung und Benutzerverwaltung folgen mit
serverseitigen Rechten.
</p>
<p class="page-intro">Abschnittskategorien, Formate und Themen im Überblick.</p>
</header>
<div class="settings-grid">
<section class="quiet-panel taxonomy-panel">
@@ -38,13 +34,6 @@
{#each data.taxonomy.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
</div>
</section>
<section class="quiet-panel planned-settings">
<p class="eyebrow">Etappe 2</p>
<h2>Konten & Rollen</h2>
<p>
Barkeeper, Thekencrew und technische Zugänge werden erst mit vollständiger Authentifizierung
verwaltbar.
</p>
</section>
</div>
<p class="settings-footnote">Konten und Rollen sind noch nicht verfügbar.</p>
</div>
+190 -34
View File
@@ -1,77 +1,232 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
test('desktop fundus is usable, filterable and keeps its theme', async ({ page }, testInfo) => {
const routes = ['/fundus', '/sendungen', '/eingang', '/archiv', '/verwaltung', '/live'];
const viewports = [
{ width: 390, height: 844, theme: 'light' },
{ width: 390, height: 844, theme: 'dark' },
{ width: 768, height: 900, theme: 'light' },
{ width: 768, height: 900, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'dark' },
{ width: 1440, height: 1000, theme: 'light' }
] as const;
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
}
async function setTheme(page: Page, theme: 'light' | 'dark') {
await page.evaluate((value) => localStorage.setItem('sendewerk-theme', value), theme);
}
test('desktop fundus is a compact, filterable list with persistent themes', async ({
page
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/fundus');
await expect(
page.getByRole('heading', { name: 'Gute Stücke, sofort griffbereit.' })
).toBeVisible();
await expect(page.getByText('Gestaltungsvorschau: Fundus')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Fundus', exact: true })).toBeVisible();
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.utility-navigation a')).toHaveText('Verwaltung');
await expect(page.locator('.piece-card')).toHaveCount(4);
const firstRow = await page.locator('.piece-card').nth(0).boundingBox();
const secondRow = await page.locator('.piece-card').nth(1).boundingBox();
expect(firstRow?.x).toBe(secondRow?.x);
expect(firstRow?.y).toBeLessThan(secondRow?.y ?? 0);
expect(firstRow?.height ?? 999).toBeLessThan(130);
const searchBox = await page.locator('.search-field').boundingBox();
const filtersBox = await page.locator('.filter-tabs').boundingBox();
expect(searchBox?.height).toBe(filtersBox?.height);
await expect(page.getByRole('button', { name: 'In Arbeit' })).toHaveCSS('white-space', 'nowrap');
const notice = page.locator('.preview-notice');
await expect(page.getByText('Vorschau · Fundus', { exact: true })).toBeVisible();
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeHidden();
await notice.locator('summary').focus();
await expect(notice.locator('summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(notice.getByText('Diese Ansicht ist schreibgeschützt.')).toBeVisible();
await page.getByRole('searchbox', { name: 'Fundus durchsuchen' }).fill('Pixelart');
await expect(page.locator('.piece-card')).toHaveCount(1);
await expect(
page.getByRole('heading', { name: 'Pixelart, die nicht nostalgisch sein will' })
).toBeVisible();
await page.getByRole('button', { name: 'In Arbeit' }).click();
await expect(page.getByRole('heading', { name: 'Kein passendes Stück' })).toBeVisible();
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click();
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.getByRole('button', { name: 'Darstellung: System' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'light');
await expect(page.getByRole('button', { name: 'Darstellung: Hell' })).toBeVisible();
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop.png'), fullPage: true });
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('fundus-desktop-light.png'), fullPage: true });
});
test('desktop builder shows fundus and run side by side', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/sendungen');
await expect(page.getByText('Gestaltungsvorschau: Sendungsbau')).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByRole('heading', { name: 'Sendungen', exact: true })).toBeVisible();
await expect(page.getByText('Herbstsendung: Kleine Welten', { exact: true })).toBeVisible();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Herbstsendung: Kleine Welten' })).toBeVisible();
await expect(page.locator('.mobile-pane-switch')).toBeHidden();
await expect(
page.locator('.show-summary strong').filter({ hasText: '1 Std. 25 Min.' })
).toBeVisible();
const boxes = await Promise.all([
page.locator('.source-pane').boundingBox(),
page.locator('.run-pane').boundingBox()
]);
expect(boxes[0]?.x).toBeLessThan(boxes[1]?.x ?? 0);
const sourceBox = await page.locator('.source-pane').boundingBox();
const runBox = await page.locator('.run-pane').boundingBox();
expect(sourceBox?.x).toBeLessThan(runBox?.x ?? 0);
expect(sourceBox?.width ?? 9999).toBeLessThan(runBox?.width ?? 0);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-desktop-dark.png'),
fullPage: true
});
});
test('mobile builder switches panes without horizontal scrolling', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
test('narrow desktop retains the sidebar and switches the builder panes', async ({ page }) => {
await page.setViewportSize({ width: 960, height: 850 });
await page.goto('/sendungen');
await expect(page.locator('.sidebar')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toBeHidden();
await expect(page.locator('.mobile-pane-switch')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await expect(page.locator('.source-pane')).toBeHidden();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
});
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
test('mobile and tablet navigation do not obscure switchable content', async ({
page
}, testInfo) => {
for (const width of [390, 768]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/sendungen');
await expect(page.locator('.sidebar')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.run-pane')).toBeVisible();
await page.getByRole('button', { name: 'Fundus', exact: true }).click();
await expect(page.locator('.source-pane')).toBeVisible();
await expect(page.locator('.run-pane')).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const verticalSpace = await page.evaluate(() => {
const navigation = document.querySelector('.mobile-navigation')?.getBoundingClientRect();
const content = document.querySelector('.source-pane')?.getBoundingClientRect();
return { contentBottom: content?.bottom ?? 0, navigationTop: navigation?.top ?? 0 };
});
expect(verticalSpace.contentBottom).toBeLessThanOrEqual(verticalSpace.navigationTop + 1);
}
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/sendungen');
await page.screenshot({ path: testInfo.outputPath('sendungen-mobile.png'), fullPage: true });
});
test('live preview exposes readable prompts and working text controls', async ({ page }) => {
test('all work areas fit at required widths in light and dark mode', async ({ page }, testInfo) => {
for (const viewport of viewports) {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/fundus');
await setTheme(page, viewport.theme);
for (const route of routes) {
await page.goto(route);
await expect(page.locator('html')).toHaveAttribute('data-theme', viewport.theme);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 768, height: 900 });
await page.goto('/fundus');
await setTheme(page, 'dark');
await page.goto('/verwaltung');
await page.screenshot({
path: testInfo.outputPath('verwaltung-tablet-dark.png'),
fullPage: true
});
});
test('long titles and longer fundus lists reflow without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
await expect(page.locator('.piece-card')).toHaveCount(4);
await page.evaluate(() => {
const list = document.querySelector('.piece-grid');
const rows = Array.from(document.querySelectorAll('.piece-card'));
const title = rows[0]?.querySelector('h2');
if (title) {
title.textContent =
'Ein außergewöhnlich langer redaktioneller Titel über kleine Konsolen, große Ideen und unerwartete Wendungen';
}
for (let repetition = 0; repetition < 3; repetition += 1) {
for (const row of rows) list?.append(row.cloneNode(true));
}
});
await expect(page.locator('.piece-card')).toHaveCount(16);
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expectNoHorizontalOverflow(page);
await expect(page.locator('.piece-card').first()).toHaveCSS('flex-direction', 'column');
const mobileCard = await page.locator('.piece-card').first().boundingBox();
const mobilePrimary = await page.locator('.piece-primary').first().boundingBox();
expect(mobilePrimary?.width ?? 0).toBeGreaterThan((mobileCard?.width ?? 0) - 40);
await expect(page.locator('.piece-card').first().getByRole('heading')).toContainText(
'Ein außergewöhnlich langer redaktioneller Titel'
);
await page.screenshot({
path: testInfo.outputPath('fundus-mobile-long-content.png'),
fullPage: true
});
});
test('empty states and administration stay concise', async ({ page }) => {
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 });
for (const route of ['/eingang', '/archiv']) {
await page.goto(route);
const emptyState = page.locator('.planned-panel');
await expect(emptyState).toBeVisible();
const box = await emptyState.boundingBox();
expect(box?.height ?? 999).toBeLessThan(150);
await expectNoHorizontalOverflow(page);
}
}
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/verwaltung');
const panels = page.locator('.taxonomy-panel');
await expect(panels).toHaveCount(3);
const boxes = await Promise.all([
panels.nth(0).boundingBox(),
panels.nth(1).boundingBox(),
panels.nth(2).boundingBox()
]);
expect(boxes[0]?.y).toBe(boxes[1]?.y);
expect(boxes[1]?.y).toBe(boxes[2]?.y);
});
test('live preview retains its existing readable controls', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/live');
await expect(page.getByText('Gestaltungsvorschau: Live-Modus')).toBeVisible();
await expect(page.getByText('Vorschau · Live-Modus', { exact: true })).toBeVisible();
await expect(page.getByText('Eine Kurbel, ein gelber Knopf und sehr viel Charme')).toBeVisible();
const before = await page
.locator('.live-current')
@@ -83,4 +238,5 @@ test('live preview exposes readable prompts and working text controls', async ({
expect(after).not.toBe(before);
await page.getByText('Recherche öffnen').click();
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
await expectNoHorizontalOverflow(page);
});