Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1f8b905cd | ||
|
|
ebc16777d6 |
No files matched your search
@@ -4,6 +4,23 @@ Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.
|
|||||||
|
|
||||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
## Editor mit einer Seitenleiste und Bewegung wie Flatsome (ab 1.11.0)
|
||||||
|
|
||||||
|
Der Editor folgt jetzt dem Aufbau des Flatsome UX Builders: eine dunkle Kopfzeile mit Gerätewahl in der Mitte, Rückgängig/Wiederholen, Bewegung abspielen, Speichern und Veröffentlichen; links **eine** Seitenleiste mit den Reitern **Elemente**, **Struktur** und **Seite**; rechts die Vorschau. Ein Klick auf ein Element in der Vorschau öffnet seine Optionen in der Seitenleiste: Name, Pfad (Seite › Sektion › Zeile), die Reiter Inhalt/Design/Erweitert, unten Verschieben, Duplizieren, Löschen. **‹** wählt das übergeordnete Element, **+** fügt in einen Container ein oder direkt hinter das Element, **✕** oder Escape hebt die Auswahl auf. Rechtsklick in der Vorschau öffnet ein Kontextmenü mit denselben Aktionen. Entf löscht das gewählte Element, sofern kein Feld den Fokus hat.
|
||||||
|
|
||||||
|
Die Elemente stehen als Kartenraster mit Symbol und Namen; was an der gewählten Stelle nicht passt, ist ausgegraut und sagt beim Überfahren warum. In der Struktur wählt ein Klick nur aus, **Bearbeiten** oder Doppelklick öffnet das Element. Die Sichtbarkeit je Gerät steht jetzt am Ende von Inhalt und Erweitert. Die verstellbaren Panelbreiten entfallen.
|
||||||
|
|
||||||
|
**Bewegung:** Einblendungen, Hover-Bewegungen und Slider-Automatik spielen standardmäßig bei jedem Besucher, wie bei Flatsome und den meisten Websites. Manche Browser melden „Bewegung reduzieren“, ohne dass jemand es eingestellt hat; bisher blieb dann jede Einblendung aus. Unter **Einstellungen → Einblendungen und Hover-Bewegungen** lässt sich „Browservorgabe beachten“ wählen; dann trägt `<html>` die Klasse `motion-respect`, und Skript wie CSS halten sich wieder an die Vorgabe. Einblendungen verstecken einen Block, bis er ins Bild scrollt, und blenden ihn dann von unsichtbar ein; wer per Sprungmarke oder schnellem Scrollen an einem Block vorbeikommt, sieht ihn sofort. Ohne JavaScript ist alles sichtbar.
|
||||||
|
|
||||||
|
Gezielt prüfen (isolierte Kopien; PHP mit SQLite3-Erweiterung):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PHP_BINARY = 'C:\php84\php.exe'
|
||||||
|
node scripts/motion-check.mjs
|
||||||
|
node scripts/editor-check.mjs
|
||||||
|
npm.cmd test
|
||||||
|
```
|
||||||
|
|
||||||
## Formularbutton und Bewegungseffekte (ab 1.10.1)
|
## Formularbutton und Bewegungseffekte (ab 1.10.1)
|
||||||
|
|
||||||
„Absenden“ übernimmt den Website-Standardbutton aus Design/Typografie: Form, Darstellung, Größe, Schrift und normale/Hover-Farben. Bestehende Formulare aktualisieren sich ohne erneutes Speichern; der Button hat seine natürliche Breite statt automatisch die gesamte Formularbreite. Formularfelder, Versand und Validierung bleiben unverändert.
|
„Absenden“ übernimmt den Website-Standardbutton aus Design/Typografie: Form, Darstellung, Größe, Schrift und normale/Hover-Farben. Bestehende Formulare aktualisieren sich ohne erneutes Speichern; der Button hat seine natürliche Breite statt automatisch die gesamte Formularbreite. Formularfelder, Versand und Validierung bleiben unverändert.
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||||
|
|
||||||
|
## ADR 091 – Eine Seitenleiste wie im Flatsome UX Builder; Bewegung spielt, bis die Website etwas anderes sagt (14.09.2026)
|
||||||
|
|
||||||
|
**Editor.** Drei Panels nebeneinander – Werkzeugkasten und Struktur links, Vorschau, Eigenschaften rechts – zeigten immer alles und damit nichts; Umut fand sich nicht zurecht, im Flatsome UX Builder schon. Der Editor übernimmt dessen Rhythmus: eine Seitenleiste, die genau eine Sache zeigt. Reiter für Elemente, Struktur und Seite; ein gewähltes Element öffnet seine eigene Ansicht mit Name, Pfad, den drei Eigenschaftenreitern und den Aktionen am Fuß und gibt sie beim Abwählen wieder frei. Die Reiter bleiben dabei erreichbar, damit Bibliothek und Baum einen Klick entfernt sind. Die Struktur wählt beim Klick nur aus und öffnet erst über „Bearbeiten“; sonst verschwände der Baum bei jedem Schritt. Rechtsklick in der Vorschau öffnet ein Kontextmenü im Editor-Dokument, dessen Position einmal aus Rahmen und Maßstab umgerechnet wird. Tastenkürzel gelten auch, wenn der Fokus in der Vorschau liegt: derselbe Handler hängt am Fenster und an jedem Vorschau-Dokument. Die Auswahl scrollt nur das Vorschau-Fenster, nicht per `scrollIntoView` – das zog das Editor-Dokument mit und schob die Kopfzeile aus dem Bild. Die verstellbaren Panelbreiten entfallen; eine feste Leiste ist der einfachere Vertrag. Bestehende IDs (`#library`, `#structure`, `#properties`, `#preview`, `#save-status` …) bleiben, die Browserprüfung wechselt die Ansicht über Helfer statt über versteckte Elemente zu klicken.
|
||||||
|
|
||||||
|
**Bewegung.** Bisher entschied allein die Browservorgabe `prefers-reduced-motion`, und die meldete Umuts Chrome als aktiv, ohne dass er sie gesetzt hatte; andere Websites bewegen sich bei ihm, weil sie die Vorgabe ignorieren. Die Entscheidung gehört der Website: Einstellung `motion` mit `always` (Standard, Flatsome-Verhalten) und `respect`. Der Server druckt bei `respect` die Klasse `motion-respect` auf `<html>`; `motion.js` und `slider.js` fragen `Klasse && Vorgabe`, das CSS schreibt die Hover-Fahrten unbedingt und nimmt sie in einem Überschreibblock `@media (prefers-reduced-motion: reduce) { html.motion-respect … { transform: none; transition: none } }` zurück – Farben und Schatten bleiben, nur die Fahrt fällt weg. Einblendungen verstecken einen wartenden Block per Inline-`opacity:0` (nur mit Skript, nur wenn Bewegung erlaubt ist) und blenden ihn mit `fill: backwards` von unsichtbar ein; ein `scroll`-Handler zeigt sofort, was oberhalb des Bildes liegt, weil ein Sprung per Sprungmarke oder Wiederherstellung sonst Blöcke dauerhaft unsichtbar ließe. Beim Abschalten (Präferenzwechsel unter `respect`, Editor-Stopp) werden alle versteckten Blöcke wieder eingeblendet. Barrierefreiheit: Einblendungen dauern unter einer Sekunde, blinken nicht und lassen sich per Einstellung an die Vorgabe binden; Lighthouse misst diese Vorgabe nicht. Kein Schema- und kein Datenbankwechsel: `motion` fällt bei fehlendem Wert auf `always` zurück.
|
||||||
|
|
||||||
## ADR 090 – Releasecache bleibt für laufende Updateanfragen erhalten (14.09.2026)
|
## ADR 090 – Releasecache bleibt für laufende Updateanfragen erhalten (14.09.2026)
|
||||||
|
|
||||||
Der echte HTTP-Updatepfad von 1.10.0 nach 1.10.1 reproduziert Umuts 500 auf `/admin/system/archiv`: Die Dateien sind bereits erfolgreich ersetzt, dann entfernt `ArchiveUpdate` den gesamten `var/cache`. Symfony lädt beim Abschluss weitere Dienste aus dem kompilierten Container nach und findet deren PHP-Dateien nicht mehr. Eine frische Installation und isolierte Updater-Rückgabewerte decken diesen Fehler nicht ab.
|
Der echte HTTP-Updatepfad von 1.10.0 nach 1.10.1 reproduziert Umuts 500 auf `/admin/system/archiv`: Die Dateien sind bereits erfolgreich ersetzt, dann entfernt `ArchiveUpdate` den gesamten `var/cache`. Symfony lädt beim Abschluss weitere Dienste aus dem kompilierten Container nach und findet deren PHP-Dateien nicht mehr. Eine frische Installation und isolierte Updater-Rückgabewerte decken diesen Fehler nicht ab.
|
||||||
|
|||||||
+14
-2
@@ -1,12 +1,24 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## 1.11.0 – Editor mit einer Seitenleiste nach Flatsome-Vorbild, Bewegung spielt standardmäßig (14.09.2026)
|
||||||
|
|
||||||
|
**Anlass:** Umut fand den Editor mit drei Panels unübersichtlich und kam mit dem Flatsome UX Builder zurecht; außerdem liefen die Einblendungen in seinem Chrome nicht, weil der Browser „Bewegung reduzieren“ meldet, obwohl er das nicht eingestellt hat – andere Websites bewegen sich bei ihm.
|
||||||
|
|
||||||
|
**Umgesetzt:** Editor-Gerüst neu (`templates/admin/editor.html.twig`, `editor.css`, `editor.js`): dunkle Kopfzeile mit Gerätewahl in der Mitte, eine Seitenleiste mit den Reitern Elemente/Struktur/Seite und einer Elementansicht (Name, Pfad, Inhalt/Design/Erweitert, Aktionen unten, ‹ zum Elternelement, + zum Einfügen, ✕ zum Schließen), Elementkarten im Raster, Kontextmenü per Rechtsklick in der Vorschau, Escape/Entf-Tasten auch bei Fokus in der Vorschau, „Bearbeiten“ neben dem gewählten Strukturknoten. Panelbreiten und der Werkzeugkasten-Umschalter entfallen. Sichtbarkeit am Ende von Inhalt und Erweitert. Bewegung: Website-Einstellung `motion` (`always` Standard, `respect`), Klasse `motion-respect` auf `<html>`, `motion.js` versteckt wartende Blöcke und blendet sie von unsichtbar ein, zeigt übersprungene Blöcke sofort; Hover-CSS ohne Medienschranke, Überschreibblock nur für `motion-respect`; `slider.js` folgt derselben Regel. ADR 091.
|
||||||
|
|
||||||
|
**Geprüft:** `scripts/motion-check.mjs` auf isolierter Pilotkopie (1600/390 px) erfolgreich: Warten außerhalb des Bildes, Start beim Scrollen, einmalige Wiedergabe, Richtungen, gespeicherter Hover, Standard spielt unter emuliertem „reduce“, `motion-respect` unterdrückt und nimmt wieder auf, Editorhinweis nur bei `motion-respect`. In der App-Vorschau mit realen Kant-IT-Daten: Auswahl, Design-Reiter, Kontextmenü, +-Einfügen (Zeile → nur Spalte erlaubt, Spalte → Überschrift), Entf, Strg+Z, Tablet-Ansicht, öffentliche Startseite mit 14 Einblendungen (7 warten, alle sichtbar nach Sprung ans Seitenende), keine JavaScript-Fehler. 33 JavaScript-Tests, Twig-Lint der geänderten Vorlagen, angepasster CSS-Test in `CmsTest`. `scripts/editor-check.mjs` auf die eine Seitenleiste umgestellt (Reiter-Helfer, Bearbeiten im Baum, Flatsome-Modus in der Sandbox) und vollständig erfolgreich, einschließlich aller axe-Prüfungen (Desktop- und Mobil-Editor, Mediathek, Verwaltungsseiten), Entwurf/Veröffentlichung, Konflikt und Einwilligung.
|
||||||
|
|
||||||
|
**Bekannt / Grenzen:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt bereits ohne diese Änderungen fehl (Menülink `Start` fehlt) – nicht Teil dieses Schritts. Kein neuer Lighthouse-Lauf. Die Ursache der Browsermeldung in Umuts Chrome bleibt offen; die Website hängt nicht mehr davon ab. Noch nicht committed oder veröffentlicht.
|
||||||
|
|
||||||
## 1.10.2 – Fehler 500 beim Abschluss des Archiv-Updateaufrufs (14.09.2026)
|
## 1.10.2 – Fehler 500 beim Abschluss des Archiv-Updateaufrufs (14.09.2026)
|
||||||
|
|
||||||
**Reproduziert und korrigiert:** Echter Produktionsmodus mit den vollständigen Paketen 1.10.0 → 1.10.1: Update-POST HTTP 500 nach erfolgreichem Dateitausch, fehlende PHP-Dienste im gerade gelöschten Containercache. Systemseite anschließend HTTP 200, Migrationsabschluss HTTP 302, öffentliche Seite danach HTTP 200. Ursache und Übergang: ADR 090. Der Kernel hält nun seinen Cachepfad pro Anfrage fest und verwendet für jede Releasefassung ein eigenes Verzeichnis. Der Archiv-Updater entfernt keine Dateien, die die laufende Anfrage noch benötigt.
|
**Reproduziert und korrigiert:** Echter Produktionsmodus mit den vollständigen Paketen 1.10.0 → 1.10.1: Update-POST HTTP 500 nach erfolgreichem Dateitausch, fehlende PHP-Dienste im gerade gelöschten Containercache. Systemseite anschließend HTTP 200, Migrationsabschluss HTTP 302, öffentliche Seite danach HTTP 200. Ursache und Übergang: ADR 090. Der Kernel hält nun seinen Cachepfad pro Anfrage fest und verwendet für jede Releasefassung ein eigenes Verzeichnis. Der Archiv-Updater entfernt keine Dateien, die die laufende Anfrage noch benötigt.
|
||||||
|
|
||||||
**Bisher geprüft:** Gegenprobe mit korrigiertem Updater: POST 302, System 200, Abschluss 302, öffentlich 200. 16 gezielte PHP-Tests / 105 Assertions für Archive, Cachepfad und Systemverwaltung erfolgreich. Neuer wiederverwendbarer Paket-HTTP-Test reproduziert den alten Fehler sowie den erfolgreichen Wiederaufnahmeweg. Die Originaldatenbank bleibt unberührt.
|
**Geprüft:** 28 PHP-Tests / 347 Assertions für Archiv- und Git-Updater, Cachepfad, Systemverwaltung und Installation erfolgreich. Wiederverwendbarer Paket-HTTP-Test reproduziert 1.10.0 → 1.10.1 mit 500 sowie den erfolgreichen Wiederaufnahmeweg (`var/archive-check-f00fc15412`). Mit dem echten Zielpaket 1.10.2: Übergang aus 1.10.1 weiterhin einmalig POST 500, danach System 200, Abschluss 302, öffentlich 200 (`var/archive-check-316de9113a`). Korrigierter Paket-Updater mit ausschließlich synthetischer vorheriger Versionsnummer: POST 302, System 200, Abschluss 302, öffentlich 200, keine fehlenden Cachedienste (`var/archive-check-8637d9310e`). Sitzung, Wartungszustand, Sicherung, Versionswechsel und unveränderte Originaldatenbank geprüft. Produktionscontainer, Cache-Warmup, PHP-Stil und JavaScript-Syntax erfolgreich.
|
||||||
|
|
||||||
**Release / nächster Schritt:** Paket 1.10.2 bauen und den Übergang aus 1.10.1 sowie einen weiteren Updateaufruf aus dem korrigierten Stand mit diesem Paket prüfen. Noch nicht veröffentlicht. Der bereits installierte alte Updater kann beim Einspielen dieser Reparatur einmalig weiter 500 ausgeben; anschließend `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen. Das ist im Pakettest ausdrücklich zu prüfen. Kein Zugriff auf Produktionsprotokolle und keine Produktionsänderung durch diese Diagnose; Startseite und Anmeldung aktuell HTTP 200.
|
**Veröffentlicht:** Commit `7c1c86b`, Tag `v1.10.2`, [Release 1.10.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.2) auf origin und als neuestes stabiles Release geprüft. Paket aus dem exakten Commit, 3543 Archivdateien und 221 Quelldateien bytegenau geprüft; nur Produktionsabhängigkeiten. Frischer Installer HTTP 200, Container und Cache-Warmup auch im Release erfolgreich. ZIP, Prüfsumme und Installer anonym heruntergeladen und mit den lokalen Artefakten verglichen. ZIP-SHA-256: `733f591c143e9d98d4044f37dabce93086542e4db2512b1d4f051617d8835711`.
|
||||||
|
|
||||||
|
**Übergabe / Grenzen:** Der bereits installierte alte Updater kann beim Einspielen dieser Reparatur einmalig weiter 500 ausgeben; anschließend `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen. Genau dieser Weg ist mit dem Releasepaket geprüft. Kein Zugriff auf Produktionsprotokolle und keine Produktionsänderung durch diese Diagnose; Startseite und Anmeldung bei der Prüfung HTTP 200. Kein neuer Lighthouse- oder visueller Gesamtlauf. Die während der Arbeit extern geänderte `.claude/launch.json` bleibt unberührt und uncommitted.
|
||||||
|
|
||||||
## 1.10.1 – Standardbutton im Formular und zuverlässiges Wiederaufnehmen von Einblendungen (14.09.2026)
|
## 1.10.1 – Standardbutton im Formular und zuverlässiges Wiederaufnehmen von Einblendungen (14.09.2026)
|
||||||
|
|
||||||
|
|||||||
+162
-187
@@ -1,12 +1,15 @@
|
|||||||
/* The editor and the media library. Only the editor and the media screen load this; the
|
/* The editor and the media library. Only the editor and the media screen load this; the
|
||||||
management screens get by with admin.css alone. Rules moved out of admin.css unchanged. */
|
management screens get by with admin.css alone.
|
||||||
|
|
||||||
|
The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that
|
||||||
|
shows one thing at a time, and the page itself filling the rest. */
|
||||||
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||||
dialog::backdrop{background:#172e2966}
|
dialog::backdrop{background:#172e2966}
|
||||||
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||||
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||||
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
|
||||||
|
|
||||||
.media-launch{margin-top:12px;white-space:normal}
|
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
|
||||||
|
.media-launch{margin-top:14px;white-space:normal}
|
||||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||||
.media-heading h2{margin:0}
|
.media-heading h2{margin:0}
|
||||||
@@ -30,108 +33,154 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.media-dialog> .panel-hint{margin-top:20px}
|
.media-dialog> .panel-hint{margin-top:20px}
|
||||||
.media-details>p{font-size:12px}
|
.media-details>p{font-size:12px}
|
||||||
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||||
.icon-button,.block-actions button,.viewport-controls button{display:inline-flex;align-items:center;justify-content:center;gap:12px;border:1px solid var(--line);border-radius:7px;padding:11px 15px;text-decoration:none;font-size:12px;font-weight:600;background:white;color:var(--ink);white-space:nowrap;min-height:40px}
|
|
||||||
.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}
|
/* ---- shell ------------------------------------------------------------------------------- */
|
||||||
.editor-body{overflow:hidden}
|
html:has(>.editor-body){overflow:hidden;height:100%}
|
||||||
#editor{height:100dvh;display:flex;flex-direction:column}
|
.editor-body{overflow:hidden;height:100dvh}
|
||||||
.editor-header{display:flex;align-items:center;gap:20px;min-height:76px;padding:13px 20px;border-bottom:1px solid var(--line);background:white;z-index:1}
|
#editor{height:100dvh;display:flex;flex-direction:column;background:#e3e7e1}
|
||||||
.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}
|
.editor-header{display:flex;align-items:center;gap:12px;height:52px;padding:0 12px;background:var(--ink);color:#fff;flex-shrink:0;z-index:5}
|
||||||
.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}
|
.back-link{display:flex;gap:7px;align-items:center;color:#cfd9d3;text-decoration:none;font-size:12px;font-weight:600;padding:8px 14px 8px 4px;border-right:1px solid #ffffff22;white-space:nowrap}
|
||||||
.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.back-link:hover{color:#fff}
|
||||||
.editor-page-name>span{font-size:10px;color:var(--muted)}
|
.editor-page-name{min-width:0;width:230px;display:grid;gap:1px}
|
||||||
.history-actions,.save-actions{display:flex;align-items:center;gap:8px}
|
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0;color:#fff}
|
||||||
.icon-button{font-size:21px;min-width:36px;padding:3px 9px}
|
.editor-page-name>span{font-size:11px;color:#9fb1a7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}
|
.icon-button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;min-height:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;text-decoration:none;font-size:14px;line-height:1}
|
||||||
.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}
|
.icon-button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||||
.left-panel,.right-panel{background:white;overflow:auto}
|
.editor-header .icon-button{color:#d5ded8}
|
||||||
.left-panel{border-right:1px solid var(--line)}
|
.editor-header .icon-button:hover:not(:disabled){background:#ffffff1a;color:#fff}
|
||||||
.right-panel{border-left:1px solid var(--line)}
|
.editor-header .icon-button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||||
.panel-section{padding:23px 18px;border-bottom:1px solid var(--line)}
|
.viewport-controls{display:flex;gap:2px;margin:0 auto;padding:3px;background:#ffffff14;border-radius:9px}
|
||||||
.panel-section .eyebrow{margin-bottom:10px}
|
.viewport-controls button{display:inline-flex;align-items:center;gap:7px;border:0;background:transparent;color:#c9d4cd;padding:0 11px;min-height:30px;border-radius:6px;font-size:12px;font-weight:600;white-space:nowrap}
|
||||||
.panel-section h2{font-size:14px;margin-bottom:12px}
|
.viewport-controls button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||||
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
|
.viewport-controls button:hover:not(:disabled){color:#fff}
|
||||||
.panel-title{display:flex;justify-content:space-between;align-items:center}
|
.editor-header .viewport-controls button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||||
.panel-title h2{margin-bottom:0}
|
.history-actions{display:flex;gap:2px}
|
||||||
.panel-title+.panel-hint{margin-top:10px}
|
.save-actions{display:flex;align-items:center;gap:8px}
|
||||||
|
.save-actions>span{font-size:11px;color:#9fb1a7;max-width:150px;text-align:right;line-height:1.3}
|
||||||
|
.editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap}
|
||||||
|
.editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99}
|
||||||
|
.editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap}
|
||||||
|
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6}
|
||||||
|
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
|
||||||
|
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
|
||||||
|
/* The single row is capped at the space left under the header, so the sidebar and the canvas
|
||||||
|
scroll inside themselves instead of stretching the workspace. */
|
||||||
|
.editor-layout{display:grid;grid-template-columns:330px minmax(0,1fr);grid-template-rows:minmax(0,1fr);flex:1;min-height:0}
|
||||||
|
|
||||||
|
/* ---- sidebar ----------------------------------------------------------------------------- */
|
||||||
|
.sidebar{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:#fff;border-right:1px solid var(--line)}
|
||||||
|
.sidebar-head{flex:none;border-bottom:1px solid var(--line);background:#fff}
|
||||||
|
.sidebar-nav{display:flex}
|
||||||
|
.sidebar-nav [role=tab]{flex:1;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.2px;padding:0 6px;min-height:46px;border-radius:0}
|
||||||
|
.sidebar-nav [role=tab]:hover:not(:disabled){color:var(--ink);background:#f7f9f5}
|
||||||
|
.sidebar-nav [aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent)}
|
||||||
|
.sidebar-element{display:flex;align-items:center;gap:4px;padding:7px 8px 7px 6px;border-top:1px solid var(--line);background:#f7f9f5}
|
||||||
|
.sidebar-element .icon-button{color:var(--ink);flex:none}
|
||||||
|
.sidebar-element .icon-button:hover:not(:disabled){background:#eef2ea}
|
||||||
|
.sidebar-element-name{flex:1;min-width:0;padding:0 4px}
|
||||||
|
.sidebar-element-name h2{margin:0;font-size:14px;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}
|
||||||
|
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:1px;min-height:0;line-height:1.3}
|
||||||
|
.breadcrumbs:empty{display:none}
|
||||||
|
.crumb{border:0;background:none;padding:0;min-height:0;font-size:10.5px;color:var(--muted);cursor:pointer;white-space:nowrap}
|
||||||
|
.crumb:hover{color:var(--accent);text-decoration:underline}
|
||||||
|
.crumb+.crumb::before{content:'›';margin:0 4px;color:#b7c2bb}
|
||||||
|
.property-tabs{display:flex;gap:4px;padding:0 8px 8px}
|
||||||
|
.property-tabs button{flex:1;min-width:0;border:1px solid var(--line);background:#f5f7f2;color:var(--ink);border-radius:7px;padding:0 6px;min-height:32px;font-size:12px;font-weight:600}
|
||||||
|
.property-tabs button:hover:not(:disabled){border-color:#b9c6bd}
|
||||||
|
.property-tabs [aria-pressed=true]{background:var(--ink);color:#fff;border-color:var(--ink)}
|
||||||
|
.sidebar-body{flex:1;min-height:0;overflow:auto;overflow-x:hidden;padding:14px 14px 28px;container-type:inline-size}
|
||||||
|
.sidebar-view>:first-child{margin-top:0}
|
||||||
|
.sidebar-foot{flex:none;display:flex;gap:6px;align-items:center;padding:10px 12px;border-top:1px solid var(--line);background:#fff}
|
||||||
|
.sidebar-foot .icon-button{border-color:var(--line);color:var(--ink)}
|
||||||
|
.sidebar-foot .icon-button:hover:not(:disabled){background:#eef2ea}
|
||||||
|
.sidebar-foot .secondary{flex:1;font-size:12px;padding:0 8px;min-height:34px;white-space:nowrap}
|
||||||
|
.sidebar-foot .danger-text{color:#943a2e}
|
||||||
|
.sidebar-note{flex:none;font-size:11px;line-height:1.5;color:#873e24;background:#fff4ec;border-top:1px solid #e6c3b2;margin:0;padding:10px 14px}
|
||||||
|
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
|
||||||
|
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:12px 0}
|
||||||
|
.sidebar label{font-size:12px;margin:16px 0 6px}
|
||||||
|
.sidebar input,.sidebar select,.sidebar textarea{font-size:12px;border-radius:7px;min-width:0;max-width:100%}
|
||||||
|
.sidebar textarea{min-height:120px}
|
||||||
|
.sidebar select{padding-inline:7px;text-overflow:ellipsis}
|
||||||
|
.sidebar .field-help{font-size:11px;line-height:1.5}
|
||||||
|
.sidebar .full-width{width:100%}
|
||||||
|
.sidebar .secondary,.sidebar .primary{font-size:12px;min-height:36px}
|
||||||
|
|
||||||
|
/* ---- element library --------------------------------------------------------------------- */
|
||||||
|
#library-search,#tree-search{font-size:12px;min-height:38px;padding:8px 11px;margin:0 0 4px}
|
||||||
|
#library-found{margin:0 0 8px;min-height:0}
|
||||||
|
#library-found:empty{display:none}
|
||||||
|
.library-group+.library-group{margin-top:18px}
|
||||||
|
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:16px 0 8px}
|
||||||
|
.library-group:first-child .library-group-title{margin-top:8px}
|
||||||
|
.library-group-hint{font-size:11px;line-height:1.5;color:var(--muted);margin:-4px 0 8px}
|
||||||
|
.library-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
|
||||||
|
.library-buttons button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;min-height:74px;padding:10px 5px 9px;border:1px solid var(--line);border-radius:9px;background:#fbfcfa;color:var(--ink);font-size:11px;line-height:1.2;text-align:center;overflow-wrap:anywhere;min-width:0}
|
||||||
|
.library-preview{display:grid;place-items:center;width:40px;height:27px;color:var(--accent)}
|
||||||
|
.library-preview svg{width:40px;height:27px}
|
||||||
|
.library-name{min-width:0}
|
||||||
|
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff;box-shadow:0 2px 10px #1b2a2414}
|
||||||
|
.library-buttons button:focus-visible{outline:2px solid #b55b12;outline-offset:1px}
|
||||||
|
.library-buttons button:disabled{opacity:.35;cursor:not-allowed}
|
||||||
|
.library-buttons button:disabled .library-preview{color:var(--muted)}
|
||||||
|
#library button[draggable=true]{cursor:grab}
|
||||||
|
#library button[draggable=true]:active{cursor:grabbing}
|
||||||
|
@container(max-width:260px){.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:8px;padding:9px 11px;font-size:11.5px;line-height:1.5;margin:0 0 12px}
|
||||||
|
.insert-hint .text-button{margin-left:4px}
|
||||||
|
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||||
|
.preset-library{display:grid;gap:6px}
|
||||||
|
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fbfcfa;color:var(--ink);font-size:12px}
|
||||||
|
.preset-library button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
||||||
|
.preset-library span{font-size:16px;color:var(--accent)}
|
||||||
|
|
||||||
|
/* ---- structure --------------------------------------------------------------------------- */
|
||||||
|
.tree-tools{display:flex;flex-wrap:wrap;gap:2px;margin:4px 0 8px}
|
||||||
|
.tree-tools .text-button{font-size:11px;padding:4px 7px;min-height:26px}
|
||||||
.structure-list{list-style:none;margin:0;padding:0}
|
.structure-list{list-style:none;margin:0;padding:0}
|
||||||
.structure-list .structure-list{padding-left:13px;border-left:1px solid #e5e9e3;margin-left:8px}
|
.structure-list .structure-list{padding-left:12px;border-left:1px solid #e5e9e3;margin-left:9px}
|
||||||
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:5px;padding:9px 7px;font-size:11px;min-height:34px}
|
.structure-list li{position:relative}
|
||||||
|
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:6px;padding:8px 7px 8px 24px;font-size:12px;min-height:34px}
|
||||||
.tree-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.tree-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.tree-item:hover{background:#f3f6ef}
|
.tree-item:hover{background:#f3f6ef}
|
||||||
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}
|
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600;padding-right:84px}
|
||||||
.tree-icon{font-size:12px;min-width:15px}
|
.tree-icon{font-size:12px;min-width:15px}
|
||||||
.tree-visibility{margin-left:auto;max-width:95px;font-size:10px;font-weight:400;line-height:1.3;color:#42584b;white-space:normal}
|
.tree-visibility{margin-left:auto;max-width:95px;font-size:10px;font-weight:400;line-height:1.3;color:#42584b;white-space:normal}
|
||||||
|
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px;min-height:0;padding:0}
|
||||||
|
.tree-edit{position:absolute;top:5px;right:4px;z-index:1;min-height:0;padding:4px 8px;border:1px solid #c5d89a;border-radius:6px;background:#fff;color:var(--accent);font-size:11px;font-weight:700}
|
||||||
|
.tree-edit:hover{background:var(--accent);color:#fff}
|
||||||
.drop-target{outline:2px dashed #315b49}
|
.drop-target{outline:2px dashed #315b49}
|
||||||
.block-actions{display:flex;flex-wrap:wrap;gap:5px;border-top:1px solid var(--line);padding-top:13px;margin-top:16px}
|
|
||||||
.block-actions button{font-size:10px;padding:7px 9px;min-height:33px}
|
|
||||||
.block-actions .danger-text{color:#943a2e}
|
|
||||||
.canvas-panel{min-width:0;display:flex;flex-direction:column;background:#e9ede7;overflow:hidden}
|
|
||||||
.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 20px;background:#f6f8f2;border-bottom:1px solid var(--line);min-height:59px}
|
|
||||||
.canvas-label{font-size:9px;font-weight:700;letter-spacing:1.2px;color:#57655d}
|
|
||||||
.viewport-controls{display:flex;gap:3px;background:#e8eee1;border-radius:7px;padding:3px}
|
|
||||||
.viewport-controls button{padding:5px 9px;font-size:10px;min-height:28px;border:0;background:transparent}
|
|
||||||
.viewport-controls [aria-pressed=true]{background:white;box-shadow:0 1px 3px #00000012}
|
|
||||||
.canvas-toolbar>a{text-decoration:none;font-size:18px}
|
|
||||||
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:22px;display:flex;justify-content:center;align-items:flex-start}
|
|
||||||
#preview{background:white;border:0;box-shadow:0 3px 20px #203b3410;min-height:600px;height:100%;flex-shrink:0}
|
|
||||||
.viewport-desktop{width:100%}
|
|
||||||
.viewport-tablet{width:768px;max-width:100%}
|
|
||||||
.viewport-mobile{width:390px;max-width:100%}
|
|
||||||
.preview-status{font-size:10px;padding:9px 20px;color:#52614f;margin:0;min-height:30px;background:#f3f5ed}
|
|
||||||
.right-panel label{font-size:11px}
|
|
||||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px}
|
|
||||||
.right-panel textarea{min-height:150px}
|
|
||||||
|
|
||||||
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:20px}
|
/* ---- canvas ------------------------------------------------------------------------------ */
|
||||||
|
.canvas-panel{min-width:0;min-height:0;display:flex;flex-direction:column;background:#e3e7e1;overflow:hidden}
|
||||||
|
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:16px}
|
||||||
|
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
||||||
|
#preview-stage{position:relative;margin:0 auto;background:#fff;box-shadow:0 8px 30px #1b2a2418;border-radius:6px;overflow:hidden}
|
||||||
|
#preview{position:absolute;top:0;left:0;border:0;background:#fff;transform-origin:top left;max-width:none;min-height:0}
|
||||||
|
.preview-status{flex:none;font-size:11px;padding:6px 14px;color:#52614f;margin:0;background:#f5f7f2;border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.resizing-panels iframe{pointer-events:none}
|
||||||
|
|
||||||
|
/* ---- context menu ------------------------------------------------------------------------ */
|
||||||
|
.context-menu{position:fixed;z-index:60;min-width:210px;padding:5px;background:#fff;border:1px solid var(--line);border-radius:9px;box-shadow:0 12px 40px #1b2a2433;display:grid}
|
||||||
|
.context-menu button{border:0;background:transparent;text-align:left;padding:8px 11px;min-height:0;border-radius:6px;font-size:12px;color:var(--ink);white-space:nowrap}
|
||||||
|
.context-menu button:hover,.context-menu button:focus-visible{background:#eef2ea;outline:0}
|
||||||
|
.context-menu .danger-text{color:#943a2e}
|
||||||
|
.context-menu .danger-text:hover{background:#fbeae6}
|
||||||
|
|
||||||
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 20px;font-size:12px}
|
/* ---- properties -------------------------------------------------------------------------- */
|
||||||
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
|
|
||||||
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
|
|
||||||
@media(max-width:1150px){.editor-layout{grid-template-columns:215px minmax(200px,1fr) 240px}.editor-header{gap:12px;padding-inline:14px}.save-actions>span{display:none}.panel-section{padding:18px 14px}.canvas-scroll{padding:12px}.canvas-label{display:none}}
|
|
||||||
@media(max-width:850px){.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}.editor-header{flex-wrap:wrap}.editor-page-name{min-width:180px}.save-actions{width:100%;justify-content:flex-end}.save-actions>span{display:block;margin-right:auto}.editor-layout{display:flex;flex-wrap:wrap}.canvas-panel{order:0;width:100%;height:680px}.left-panel{order:1;width:45%;max-height:650px}.right-panel{order:2;width:55%;max-height:650px}.canvas-label{display:block}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
|
|
||||||
.left-panel{display:flex;flex-direction:column;overflow:hidden}
|
|
||||||
.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}
|
|
||||||
.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
||||||
.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}
|
|
||||||
.structure-section .block-actions{flex-shrink:0;background:white}
|
|
||||||
.structure-section .panel-hint{margin:8px 0}
|
|
||||||
.search-label{font-size:11px;margin:12px 0 6px}
|
|
||||||
#tree-search{font-size:11px;padding:9px;min-height:36px}
|
|
||||||
.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}
|
|
||||||
.tree-tools .text-button{font-size:10px;padding:5px}
|
|
||||||
.structure-list li{position:relative}
|
|
||||||
.tree-item{padding-left:23px}
|
|
||||||
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}
|
|
||||||
.preset-heading{font-size:12px;margin:22px 0 8px}
|
|
||||||
.preset-library{display:grid;gap:6px}
|
|
||||||
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
|
|
||||||
.preset-library button:hover:not(:disabled){border-color:var(--accent)}
|
|
||||||
.preset-library span{font-size:18px}
|
|
||||||
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
|
|
||||||
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
|
|
||||||
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
|
|
||||||
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
|
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
|
||||||
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
|
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
|
||||||
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
|
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
|
||||||
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
|
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:0 0 8px;padding:9px 10px;border-left:3px solid var(--accent);background:#f1f5e7;border-radius:0 7px 7px 0}
|
||||||
.design-reset{margin-top:24px;white-space:normal;width:100%}
|
.design-reset{margin-top:24px;white-space:normal;width:100%}
|
||||||
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
|
.design-group{border:1px solid var(--line);border-radius:10px;margin-top:10px;background:#fff}
|
||||||
.breadcrumbs .text-button{font-size:10px;white-space:normal}
|
.design-group>summary{cursor:pointer;padding:11px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||||
.breadcrumbs button+button:before{content:'›';color:var(--muted)}
|
.design-group>summary::-webkit-details-marker{display:none}
|
||||||
.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}
|
.design-group>summary::after{content:'+';font-size:17px;color:var(--muted)}
|
||||||
.canvas-scroll{display:block}
|
|
||||||
.canvas-scroll #preview-stage{position:relative;margin:0 auto}
|
|
||||||
#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}
|
|
||||||
.right-panel select{padding-inline:7px;text-overflow:ellipsis}
|
|
||||||
.right-panel .panel-section{min-width:0}
|
|
||||||
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
|
|
||||||
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
|
|
||||||
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
|
||||||
.design-group>summary::after{content:'+';font-size:17px}
|
|
||||||
.design-group[open]>summary{border-bottom:1px solid var(--line)}
|
.design-group[open]>summary{border-bottom:1px solid var(--line)}
|
||||||
.design-group[open]>summary::after{content:'–'}
|
.design-group[open]>summary::after{content:'–'}
|
||||||
.design-group-body{padding:6px 14px 16px}
|
.design-group-body{padding:4px 14px 16px}
|
||||||
|
.design-group-body>label:first-child{margin-top:10px}
|
||||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||||
.design-row{margin-top:14px}
|
.design-row{margin-top:14px}
|
||||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||||
@@ -140,10 +189,11 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
|
||||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||||
.length-control.is-inherited input{color:var(--muted)}
|
.length-control.is-inherited input{color:var(--muted)}
|
||||||
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
|
.length-mode{grid-column:1/-1}
|
||||||
|
.number-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:8px;align-items:center}
|
||||||
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
|
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
|
||||||
.number-control input[type=number]{font-size:12px;min-height:38px}
|
.number-control input[type=number]{font-size:12px;min-height:38px}
|
||||||
.side-wrap{margin-bottom:10px}
|
.side-wrap{margin-bottom:10px}
|
||||||
@@ -160,7 +210,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
||||||
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
||||||
.side-unit-select{flex:1;min-width:0}
|
.side-unit-select{flex:1;min-width:0}
|
||||||
|
|
||||||
/* The device chips on a single option: which devices carry an own value, and switch to one. */
|
/* The device chips on a single option: which devices carry an own value, and switch to one. */
|
||||||
.device-chips{display:flex;gap:2px;margin-left:auto}
|
.device-chips{display:flex;gap:2px;margin-left:auto}
|
||||||
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
|
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
|
||||||
@@ -169,16 +218,14 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
||||||
.design-side-head{margin-bottom:5px}
|
.design-side-head{margin-bottom:5px}
|
||||||
.design-side-head .design-group-label{margin:0}
|
.design-side-head .design-group-label{margin:0}
|
||||||
|
|
||||||
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
||||||
.scrubbable{cursor:ew-resize}
|
.scrubbable{cursor:ew-resize}
|
||||||
.scrubbable:focus{cursor:text}
|
.scrubbable:focus{cursor:text}
|
||||||
.design-actions{display:grid;gap:8px;margin-top:22px}
|
.design-actions{display:grid;gap:8px;margin-top:22px}
|
||||||
.design-actions button{width:100%}
|
.design-actions button{width:100%}
|
||||||
|
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
|
||||||
|
#design-search{font-size:12px;min-height:36px;padding:9px}
|
||||||
|
.search-label{font-size:11px;margin:12px 0 6px}
|
||||||
|
|
||||||
.media-control{display:grid;gap:8px}
|
.media-control{display:grid;gap:8px}
|
||||||
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
|
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
|
||||||
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
|
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
|
||||||
@@ -191,9 +238,10 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
|
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
|
||||||
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
|
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
|
||||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
||||||
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
.rt-link{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||||
.rt-link input{font-size:11px;min-height:30px}
|
.rt-link input{font-size:11px;min-height:30px}
|
||||||
.rt-link{flex-wrap:wrap;align-items:center}.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
|
.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}
|
||||||
|
.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
|
||||||
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
||||||
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||||
@@ -201,27 +249,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
|
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
|
||||||
.own-code{font-family:ui-monospace,monospace;font-size:12px;line-height:1.6;min-height:110px}
|
.own-code{font-family:ui-monospace,monospace;font-size:12px;line-height:1.6;min-height:110px}
|
||||||
.own-code[readonly]{background:#f4f5f1;color:var(--muted)}
|
.own-code[readonly]{background:#f4f5f1;color:var(--muted)}
|
||||||
/* Bringing the editor chrome in line with the new administration: same radii, same restraint,
|
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
|
||||||
and a few sizes that were simply too small to read comfortably. The layout is unchanged. */
|
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
|
||||||
.editor-header{min-height:70px}
|
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
|
||||||
.back-link{font-size:12px;font-weight:600;gap:7px;align-items:center}
|
|
||||||
.editor-page-name h1{font-size:14px}
|
|
||||||
.panel-section h2{font-size:13px;letter-spacing:0}
|
|
||||||
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
|
|
||||||
.tree-item{font-size:12px;border-radius:6px}
|
|
||||||
.right-panel label{font-size:12px}
|
|
||||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
|
|
||||||
.property-tabs{padding:3px;border-radius:9px}
|
|
||||||
.property-tabs button{border-radius:7px;font-size:12px}
|
|
||||||
.viewport-controls{border-radius:8px}
|
|
||||||
.viewport-controls button{border-radius:6px;font-size:11px}
|
|
||||||
.design-group{border-radius:10px}
|
|
||||||
.design-scope{border-radius:0 7px 7px 0}
|
|
||||||
#preview{box-shadow:0 6px 28px #1b2a2414}
|
|
||||||
.canvas-panel{background:#e7ebe5}
|
|
||||||
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
|
|
||||||
.icon-button{border-radius:8px}
|
|
||||||
#design-search{font-size:12px;min-height:36px;padding:9px}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------------------------
|
||||||
The working layer above the preview.
|
The working layer above the preview.
|
||||||
@@ -235,8 +265,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.ov-box{position:absolute;pointer-events:none}
|
.ov-box{position:absolute;pointer-events:none}
|
||||||
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
||||||
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||||
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:#5c6b62;color:#fff;font:600 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
||||||
.ov-label{background:#5c6b62;font-weight:600}
|
|
||||||
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
||||||
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
||||||
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
||||||
@@ -250,68 +279,14 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||||
/* While something is being dragged the outlines would only add noise. */
|
/* While something is being dragged the outlines would only add noise. */
|
||||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||||
|
|
||||||
#library button[draggable=true]{cursor:grab}
|
|
||||||
#library button[draggable=true]:active{cursor:grabbing}
|
|
||||||
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
|
|
||||||
.insert-hint .text-button{margin-left:4px}
|
|
||||||
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
|
||||||
|
|
||||||
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
|
|
||||||
fade on the insert points is a transition and is switched off with the rest. */
|
|
||||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------------------------
|
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||||
The toolbox, grouped the way a page is built.
|
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
|
||||||
|
@media(max-width:850px){
|
||||||
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
|
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
|
||||||
element would land is disabled and carries the reason in its title, so the rule is learned
|
.editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto}
|
||||||
rather than merely enforced.
|
.editor-layout{display:flex;flex-direction:column}
|
||||||
-------------------------------------------------------------------------------------------- */
|
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
|
||||||
.library-group + .library-group{margin-top:16px}
|
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
|
}
|
||||||
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
|
|
||||||
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
|
||||||
.library-buttons button{display:flex;align-items:center;gap:8px;text-align:left;min-height:44px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
|
|
||||||
.library-preview{flex:none;display:grid;place-items:center;width:34px;height:23px;color:var(--accent);opacity:.85}
|
|
||||||
.library-buttons button:disabled .library-preview{color:var(--muted);opacity:.6}
|
|
||||||
.library-name{min-width:0}
|
|
||||||
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
|
||||||
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
|
|
||||||
.library-buttons button[data-row]{grid-column:span 2}
|
|
||||||
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
|
|
||||||
|
|
||||||
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
|
|
||||||
#library-found{margin:0 0 10px;min-height:0}
|
|
||||||
|
|
||||||
/* Flexible workspace: both panels use their full width, with accessible resize handles. */
|
|
||||||
.editor-layout{grid-template-columns:var(--panel-left,280px) 6px minmax(280px,1fr) 6px var(--panel-right,320px)}
|
|
||||||
.left-panel,.right-panel{min-width:0;overflow-x:hidden}
|
|
||||||
.left-panel{container-type:inline-size}
|
|
||||||
.left-panel>.section-launch{flex:none;padding:12px;border-bottom:1px solid var(--line)}
|
|
||||||
.section-launch button{white-space:normal;font-size:12px;min-height:40px}
|
|
||||||
.section-launch .field-help{margin:6px 0 0}
|
|
||||||
.left-panel>.panel-section:not(.structure-section){max-height:55%;overflow:auto;flex-shrink:0}
|
|
||||||
.panel-section{padding:16px 12px}
|
|
||||||
.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
||||||
.library-buttons button{min-width:0;white-space:normal;overflow-wrap:anywhere;gap:6px}
|
|
||||||
.library-name{overflow-wrap:anywhere}
|
|
||||||
.library-preview{width:28px}
|
|
||||||
@container(max-width:270px){.library-buttons{grid-template-columns:minmax(0,1fr)}.library-buttons button[data-row]{grid-column:auto}}
|
|
||||||
.canvas-scroll{padding:0}
|
|
||||||
.panel-resizer{background:#e4e9df;cursor:col-resize;touch-action:none;position:relative;z-index:4}
|
|
||||||
.panel-resizer::after{content:'';position:absolute;top:45%;left:1px;width:4px;height:36px;border-radius:4px;background:#7b8d80}
|
|
||||||
.panel-resizer:hover,.panel-resizer:focus-visible{background:#b7cbbd;outline:2px solid var(--accent);outline-offset:-2px}
|
|
||||||
.resizing-panels{cursor:col-resize;user-select:none}
|
|
||||||
.resizing-panels iframe{pointer-events:none}
|
|
||||||
.length-control,.number-control{grid-template-columns:minmax(0,1fr) 74px}
|
|
||||||
.right-panel input,.right-panel select,.right-panel textarea{min-width:0;max-width:100%}
|
|
||||||
.length-mode{grid-column:1/-1}
|
|
||||||
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
|
|
||||||
@media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}}
|
|
||||||
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
|
|
||||||
|
|
||||||
.canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px}
|
|
||||||
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
|
|
||||||
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
|
|
||||||
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
|
|
||||||
+207
-104
@@ -33,15 +33,45 @@ let renderedProperties = null;
|
|||||||
let revealAfterPreview = null;
|
let revealAfterPreview = null;
|
||||||
let previewPlayback = false, stopPlayback = () => {};
|
let previewPlayback = false, stopPlayback = () => {};
|
||||||
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
|
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
const showMotionStatus = () => { $('#motion-status').hidden = !motionPreference.matches; };
|
// Worth a note only when the website actually follows the browser (Einstellungen → Bewegung);
|
||||||
|
// the preview carries that decision as a class on its <html>.
|
||||||
|
const showMotionStatus = () => {
|
||||||
|
const respects = $('#preview').contentDocument?.documentElement.classList.contains('motion-respect') ?? false;
|
||||||
|
$('#motion-status').hidden = !(motionPreference.matches && respects);
|
||||||
|
};
|
||||||
motionPreference.addEventListener('change', showMotionStatus);
|
motionPreference.addEventListener('change', showMotionStatus);
|
||||||
showMotionStatus();
|
showMotionStatus();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// The sidebar shows one thing at a time.
|
||||||
|
//
|
||||||
|
// `elements` is the library, `structure` the tree, `page` the page settings and `element` the
|
||||||
|
// options of the selected block. The last one is not a tab: it appears when something is
|
||||||
|
// selected and gives way to the library when the selection is dropped - the Flatsome rhythm of
|
||||||
|
// "pick, edit, back to the list".
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
let view = 'elements';
|
||||||
|
function setView(name) {
|
||||||
|
view = name;
|
||||||
|
const sidebar = $('.sidebar');
|
||||||
|
sidebar.dataset.view = name;
|
||||||
|
const showsProperties = name === 'element' || name === 'page';
|
||||||
|
for (const section of sidebar.querySelectorAll('.sidebar-view')) section.hidden = section.dataset.view !== (showsProperties ? 'element' : name);
|
||||||
|
// The tabs stay in reach while an element is open, so the library and the tree are one click
|
||||||
|
// away; none of them is marked then, because the element view is not a tab.
|
||||||
|
for (const tab of sidebar.querySelectorAll('.sidebar-nav [role=tab]')) tab.setAttribute('aria-selected', String(tab.dataset.view === name));
|
||||||
|
$('.sidebar-element').hidden = name !== 'element';
|
||||||
|
$('.property-tabs').hidden = name !== 'element';
|
||||||
|
$('.sidebar-foot').hidden = name !== 'element';
|
||||||
|
$('.sidebar-body').scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
function playPreview() {
|
function playPreview() {
|
||||||
stopPlayback(); stopPlayback = () => {};
|
stopPlayback(); stopPlayback = () => {};
|
||||||
const button = $('#preview-motion');
|
const button = $('#preview-motion');
|
||||||
button.setAttribute('aria-pressed', String(previewPlayback));
|
button.setAttribute('aria-pressed', String(previewPlayback));
|
||||||
button.textContent = previewPlayback ? 'Bewegung stoppen' : 'Bewegung testen';
|
button.title = previewPlayback ? 'Bewegung in der Vorschau stoppen' : 'Bewegung in der Vorschau abspielen';
|
||||||
|
button.setAttribute('aria-label', button.title);
|
||||||
const doc = $('#preview').contentDocument;
|
const doc = $('#preview').contentDocument;
|
||||||
if (previewPlayback && doc?.body) {
|
if (previewPlayback && doc?.body) {
|
||||||
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
|
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
|
||||||
@@ -86,20 +116,19 @@ function controls() {
|
|||||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||||
$('#redo').disabled = !ready || busy || !history.future.length;
|
$('#redo').disabled = !ready || busy || !history.future.length;
|
||||||
const current = ready ? locate(history.document.blocks, selected) : null;
|
const current = ready ? locate(history.document.blocks, selected) : null;
|
||||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent']) $(`#${id}`).disabled = !current || busy;
|
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
|
||||||
if (current) {
|
if (current) {
|
||||||
$('#move-up').disabled ||= current.index === 0;
|
$('#move-up').disabled ||= current.index === 0;
|
||||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||||
$('#outdent').disabled ||= !current.parent;
|
$('#outdent').disabled ||= !current.parent;
|
||||||
|
$('#go-parent').disabled ||= !current.parent;
|
||||||
}
|
}
|
||||||
updateLibrary();
|
updateLibrary();
|
||||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||||
$('[data-panel="design"]').disabled ||= !current;
|
$('[data-panel="design"]').disabled ||= !current;
|
||||||
$('#select-page').disabled = !ready || busy;
|
|
||||||
$('#add-root-section').disabled = !ready || busy;
|
|
||||||
$('#open-media-library').disabled = !ready || busy;
|
$('#open-media-library').disabled = !ready || busy;
|
||||||
$('#preview-motion').disabled = !ready || busy;
|
$('#preview-motion').disabled = !ready || busy;
|
||||||
}
|
}
|
||||||
@@ -136,6 +165,14 @@ function renderTree() {
|
|||||||
button.title = badge.textContent; button.append(badge);
|
button.title = badge.textContent; button.append(badge);
|
||||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText} – ${badge.textContent}`);
|
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText} – ${badge.textContent}`);
|
||||||
}
|
}
|
||||||
|
if (block.id === selected) {
|
||||||
|
// In the tree a click only selects, so the tree stays open while moving through the
|
||||||
|
// page. Editing is one step further - this button, or a double click.
|
||||||
|
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'tree-edit';
|
||||||
|
edit.dataset.editBlock = block.id; edit.textContent = 'Bearbeiten';
|
||||||
|
edit.setAttribute('aria-label', `${blockLabel(block)} bearbeiten`);
|
||||||
|
item.append(edit);
|
||||||
|
}
|
||||||
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||||||
list.append(item);
|
list.append(item);
|
||||||
}
|
}
|
||||||
@@ -153,15 +190,31 @@ function choose(id, scroll = true) {
|
|||||||
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
||||||
if (!selected) propertyPanel = 'content';
|
if (!selected) propertyPanel = 'content';
|
||||||
history.mergeKey = null;
|
history.mergeKey = null;
|
||||||
|
syncView();
|
||||||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selecting opens the element; dropping the selection goes back to the library. The tree is
|
||||||
|
* the exception: there the selection only moves the highlight, editing is one more step.
|
||||||
|
*/
|
||||||
|
function syncView() {
|
||||||
|
if (selected && view !== 'structure') setView('element');
|
||||||
|
else if (!selected && view === 'element') setView('elements');
|
||||||
|
}
|
||||||
|
|
||||||
function highlight(scroll = false) {
|
function highlight(scroll = false) {
|
||||||
const doc = $('#preview').contentDocument;
|
const doc = $('#preview').contentDocument;
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||||||
const element = selected ? doc.getElementById(selected) : null;
|
const element = selected ? doc.getElementById(selected) : null;
|
||||||
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
if (element) {
|
||||||
|
element.classList.add('editor-selected');
|
||||||
|
// Scroll the preview window only. scrollIntoView would also drag the editor document along
|
||||||
|
// with it, and the whole workspace would slide out from under the header.
|
||||||
|
const win = doc.defaultView, box = element.getBoundingClientRect();
|
||||||
|
if (scroll && win && (box.top < 0 || box.bottom > win.innerHeight)) win.scrollTo({ top: win.scrollY + box.top - 40 });
|
||||||
|
}
|
||||||
overlay?.sync();
|
overlay?.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +222,15 @@ function renderProperties() {
|
|||||||
const block = locate(history.document.blocks, selected)?.block;
|
const block = locate(history.document.blocks, selected)?.block;
|
||||||
if (!block) propertyPanel = 'content';
|
if (!block) propertyPanel = 'content';
|
||||||
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
||||||
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
|
if (context !== renderedProperties) $('.sidebar-body').scrollTop = 0;
|
||||||
renderedProperties = context;
|
renderedProperties = context;
|
||||||
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
|
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
|
||||||
const panel = $('#properties'); panel.replaceChildren();
|
const panel = $('#properties'); panel.replaceChildren();
|
||||||
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||||||
|
// The way up, as a trail above the name: page, then every container the element sits in.
|
||||||
$('#breadcrumbs').replaceChildren();
|
$('#breadcrumbs').replaceChildren();
|
||||||
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
|
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)].slice(0, -1)) {
|
||||||
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? blockLabel(item) : 'Seite';
|
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
|
||||||
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
|
|
||||||
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||||||
}
|
}
|
||||||
if (block && isPluginBlock(block)) {
|
if (block && isPluginBlock(block)) {
|
||||||
@@ -188,21 +241,33 @@ function renderProperties() {
|
|||||||
panel.append(note);
|
panel.append(note);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (block) {
|
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); renderVisibility(block, panel); return; }
|
||||||
const label = document.createElement('label'); label.htmlFor = 'block-visibility'; label.textContent = 'Sichtbarkeit';
|
|
||||||
const select = document.createElement('select'); select.id = 'block-visibility';
|
|
||||||
for (const [value, text] of Object.entries(VISIBILITY)) select.append(new Option(text, value));
|
|
||||||
select.value = block.layout?.visibility ?? 'all';
|
|
||||||
select.addEventListener('change', () => {
|
|
||||||
history.change(doc => setVisibility(doc, block.id, select.value));
|
|
||||||
renderTree(); controls(); schedulePreview();
|
|
||||||
});
|
|
||||||
const help = document.createElement('p'); help.className = 'field-help'; help.id = 'visibility-help';
|
|
||||||
help.textContent = 'Desktop ab 1025 px · Tablet 768–1024 px · Mobil bis 767 px. Ausgeblendete Elemente bleiben links in der Struktur auswählbar. Ein ausgeblendeter Elternbereich blendet auch seinen Inhalt aus.';
|
|
||||||
select.setAttribute('aria-describedby', help.id); panel.append(label, select, help);
|
|
||||||
}
|
|
||||||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
|
||||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||||
|
renderContent(block, panel);
|
||||||
|
if (block) renderVisibility(block, panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On which devices the element is shown. Part of the content, so it sits at its end. */
|
||||||
|
function renderVisibility(block, panel) {
|
||||||
|
const group = document.createElement('details'); group.className = 'design-group';
|
||||||
|
group.open = (block.layout?.visibility ?? 'all') !== 'all';
|
||||||
|
const summary = document.createElement('summary'); summary.textContent = 'Sichtbarkeit';
|
||||||
|
const body = document.createElement('div'); body.className = 'design-group-body';
|
||||||
|
const label = document.createElement('label'); label.htmlFor = 'block-visibility'; label.textContent = 'Anzeigen';
|
||||||
|
const select = document.createElement('select'); select.id = 'block-visibility';
|
||||||
|
for (const [value, text] of Object.entries(VISIBILITY)) select.append(new Option(text, value));
|
||||||
|
select.value = block.layout?.visibility ?? 'all';
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
history.change(doc => setVisibility(doc, block.id, select.value));
|
||||||
|
renderTree(); controls(); schedulePreview();
|
||||||
|
});
|
||||||
|
const help = document.createElement('p'); help.className = 'field-help'; help.id = 'visibility-help';
|
||||||
|
help.textContent = 'Desktop ab 1025 px · Tablet 768–1024 px · Mobil bis 767 px. Ausgeblendete Elemente bleiben in der Struktur auswählbar.';
|
||||||
|
select.setAttribute('aria-describedby', help.id);
|
||||||
|
body.append(label, select, help); group.append(summary, body); panel.append(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(block, panel) {
|
||||||
let imagePreview;
|
let imagePreview;
|
||||||
const field = (label, key, value, options = {}) => {
|
const field = (label, key, value, options = {}) => {
|
||||||
const container = options.container || panel;
|
const container = options.container || panel;
|
||||||
@@ -802,6 +867,16 @@ async function updatePreview() {
|
|||||||
const block = locate(history.document.blocks, element.id)?.block;
|
const block = locate(history.document.blocks, element.id)?.block;
|
||||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||||
});
|
});
|
||||||
|
// Right click: the element's actions where the pointer is, as in Flatsome.
|
||||||
|
doc.addEventListener('contextmenu', event => {
|
||||||
|
const element = event.target.closest('[id]');
|
||||||
|
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
|
||||||
|
event.preventDefault();
|
||||||
|
choose(element.id, false);
|
||||||
|
openContextMenu(element.id, event.clientX, event.clientY);
|
||||||
|
});
|
||||||
|
doc.addEventListener('mousedown', event => { if (event.button === 0) closeContextMenu(); });
|
||||||
|
doc.addEventListener('keydown', onKey);
|
||||||
doc.addEventListener('submit', event => event.preventDefault());
|
doc.addEventListener('submit', event => event.preventDefault());
|
||||||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
@@ -814,6 +889,7 @@ async function updatePreview() {
|
|||||||
highlight(revealAfterPreview === selected); revealAfterPreview = null;
|
highlight(revealAfterPreview === selected); revealAfterPreview = null;
|
||||||
markInsertTarget();
|
markInsertTarget();
|
||||||
frame.style.visibility = '';
|
frame.style.visibility = '';
|
||||||
|
showMotionStatus();
|
||||||
playPreview();
|
playPreview();
|
||||||
$('#preview-status').textContent = loaded
|
$('#preview-status').textContent = loaded
|
||||||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||||||
@@ -853,6 +929,7 @@ async function updatePreview() {
|
|||||||
function changed() {
|
function changed() {
|
||||||
if (!locate(history.document.blocks, selected)) selected = null;
|
if (!locate(history.document.blocks, selected)) selected = null;
|
||||||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||||||
|
syncView();
|
||||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,9 +991,9 @@ function addElement(type) {
|
|||||||
if (busy || !history) return;
|
if (busy || !history) return;
|
||||||
const at = pendingInsert;
|
const at = pendingInsert;
|
||||||
setPendingInsert(null);
|
setPendingInsert(null);
|
||||||
selected = history.change(doc => at
|
selected = history.change(doc => at && type !== 'section'
|
||||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||||
: insertBlock(doc, type, selected));
|
: insertBlock(doc, type, at?.parentId ?? selected));
|
||||||
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
|
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
|
||||||
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
|
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
|
||||||
changed();
|
changed();
|
||||||
@@ -954,12 +1031,11 @@ function setPendingInsert(target) {
|
|||||||
$('#insert-hint').hidden = !target;
|
$('#insert-hint').hidden = !target;
|
||||||
markInsertTarget();
|
markInsertTarget();
|
||||||
if (target) {
|
if (target) {
|
||||||
if ($('#toolbox').hidden) $('#toggle-library').click();
|
setView('elements');
|
||||||
$('#library-search').value = ''; filterLibrary();
|
$('#library-search').value = ''; filterLibrary();
|
||||||
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
|
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
|
||||||
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
|
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
|
||||||
$('#library-search').focus({ preventScroll: true });
|
$('#library-search').focus({ preventScroll: true });
|
||||||
$('#library-search').scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,10 +1074,10 @@ function buildLibrary() {
|
|||||||
button.type = 'button'; button.draggable = true;
|
button.type = 'button'; button.draggable = true;
|
||||||
button.dataset.add = type;
|
button.dataset.add = type;
|
||||||
if (columns) button.dataset.row = String(columns);
|
if (columns) button.dataset.row = String(columns);
|
||||||
const name = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
|
const name = columns ? `${columns} Spalten` : labels[type];
|
||||||
// What is searched for: the name, the internal type and the group it sits in. Someone
|
// What is searched for: the name, the internal type and the group it sits in. Someone
|
||||||
// looking for "slider" should not have to know it is filed under "Gruppen".
|
// looking for "slider" should not have to know it is filed under "Gruppen".
|
||||||
button.dataset.search = `${name} ${type} ${category.name}`.toLocaleLowerCase('de');
|
button.dataset.search = `${name} ${columns ? labels[type] + ' ' : ''}${type} ${category.name}`.toLocaleLowerCase('de');
|
||||||
const picture = document.createElement('span');
|
const picture = document.createElement('span');
|
||||||
picture.className = 'library-preview';
|
picture.className = 'library-preview';
|
||||||
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
||||||
@@ -1027,9 +1103,10 @@ function updateLibrary() {
|
|||||||
for (const button of $('#library').querySelectorAll('button')) {
|
for (const button of $('#library').querySelectorAll('button')) {
|
||||||
const type = button.dataset.add;
|
const type = button.dataset.add;
|
||||||
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
|
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
|
||||||
const target = pendingInsert
|
// A section always has a place - on the page, after the area the insert point sits in.
|
||||||
|
const target = pendingInsert && type !== 'section'
|
||||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||||
: insertionTarget(history.document, selected, type);
|
: insertionTarget(history.document, pendingInsert?.parentId ?? selected, type);
|
||||||
button.disabled = !target;
|
button.disabled = !target;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
|
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
|
||||||
@@ -1074,13 +1151,22 @@ $('#library').addEventListener('click', event => {
|
|||||||
}
|
}
|
||||||
addElement(button.dataset.add);
|
addElement(button.dataset.add);
|
||||||
});
|
});
|
||||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
$('#cancel-insert').addEventListener('click', () => { setPendingInsert(null); if (selected) setView('element'); });
|
||||||
$('#add-root-section').addEventListener('click', () => {
|
// The tabs at the top of the sidebar. "Seite" also drops the selection: its settings are what
|
||||||
|
// the page shows when nothing is chosen.
|
||||||
|
for (const tab of document.querySelectorAll('.sidebar-nav [role=tab]')) tab.addEventListener('click', () => {
|
||||||
if (busy || !history) return;
|
if (busy || !history) return;
|
||||||
stopInlineEdit();
|
if (tab.dataset.view === 'page') { setView('page'); choose(null); return; }
|
||||||
setPendingInsert(null);
|
setView(tab.dataset.view);
|
||||||
selected = history.change(doc => insertBlock(doc, 'section', selected));
|
});
|
||||||
propertyPanel = 'content'; changed();
|
$('#go-parent').addEventListener('click', () => actions.parent(selected));
|
||||||
|
$('#close-element').addEventListener('click', () => choose(null));
|
||||||
|
// The plus next to the name: into a container, otherwise behind the element.
|
||||||
|
$('#add-here').addEventListener('click', () => {
|
||||||
|
const item = locate(history.document.blocks, selected);
|
||||||
|
if (!item || busy) return;
|
||||||
|
if (isContainer(item.block) && item.block.type !== 'accordion') beginContainerInsert(item.block.id);
|
||||||
|
else setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + 1 });
|
||||||
});
|
});
|
||||||
$('#presets').addEventListener('click', event => {
|
$('#presets').addEventListener('click', event => {
|
||||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||||
@@ -1090,15 +1176,15 @@ $('#presets').addEventListener('click', event => {
|
|||||||
$('#structure').addEventListener('click', event => {
|
$('#structure').addEventListener('click', event => {
|
||||||
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
|
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
|
||||||
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
|
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
|
||||||
|
const edit = event.target.closest('[data-edit-block]')?.dataset.editBlock;
|
||||||
|
if (edit) { setView('element'); choose(edit); return; }
|
||||||
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
|
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
|
||||||
});
|
});
|
||||||
$('#tree-search').addEventListener('input', renderTree);
|
$('#structure').addEventListener('dblclick', event => {
|
||||||
$('#toggle-library').addEventListener('click', () => {
|
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||||
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
|
if (id && !busy) { setView('element'); choose(id); }
|
||||||
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
|
|
||||||
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
|
|
||||||
$('#toggle-library').textContent = hidden ? '+' : '−';
|
|
||||||
});
|
});
|
||||||
|
$('#tree-search').addEventListener('input', renderTree);
|
||||||
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
|
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
|
||||||
$('#collapse-all').addEventListener('click', () => {
|
$('#collapse-all').addEventListener('click', () => {
|
||||||
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
|
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
|
||||||
@@ -1149,6 +1235,53 @@ const actions = {
|
|||||||
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
|
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// The context menu of the preview. Lives in the editor document, so it is drawn at full size
|
||||||
|
// above a scaled preview; the frame coordinates are converted once, here.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
function openContextMenu(id, frameX, frameY) {
|
||||||
|
const item = locate(history.document.blocks, id);
|
||||||
|
if (!item) return;
|
||||||
|
const menu = $('#context-menu');
|
||||||
|
const label = blockLabel(item.block);
|
||||||
|
const sibling = dropList(history.document, item.parent?.id ?? null, item.block);
|
||||||
|
const entries = [
|
||||||
|
{ text: `${label} bearbeiten`, run: () => { setView('element'); choose(id, false); } },
|
||||||
|
isContainer(item.block) && item.block.type !== 'accordion' ? { text: 'Element hinein', run: () => beginContainerInsert(id) } : null,
|
||||||
|
sibling ? { text: 'Element davor', run: () => setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index }) } : null,
|
||||||
|
sibling ? { text: 'Element dahinter', run: () => setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + 1 }) } : null,
|
||||||
|
{ text: 'Duplizieren', run: () => actions.duplicate(id) },
|
||||||
|
item.index > 0 ? { text: 'Nach oben', run: () => actions.move(id, -1) } : null,
|
||||||
|
item.index < item.siblings.length - 1 ? { text: 'Nach unten', run: () => actions.move(id, 1) } : null,
|
||||||
|
item.parent ? { text: `Übergeordnet: ${blockLabel(item.parent)}`, run: () => actions.parent(id) } : null,
|
||||||
|
{ text: 'Löschen', danger: true, run: () => actions.delete(id) },
|
||||||
|
].filter(Boolean);
|
||||||
|
menu.replaceChildren(...entries.map(entry => {
|
||||||
|
const button = document.createElement('button'); button.type = 'button'; button.setAttribute('role', 'menuitem');
|
||||||
|
button.textContent = entry.text; if (entry.danger) button.className = 'danger-text';
|
||||||
|
button.addEventListener('click', () => { closeContextMenu(); entry.run(); });
|
||||||
|
return button;
|
||||||
|
}));
|
||||||
|
const frame = $('#preview').getBoundingClientRect();
|
||||||
|
menu.hidden = false;
|
||||||
|
const x = frame.left + frameX * previewScale, y = frame.top + frameY * previewScale;
|
||||||
|
menu.style.left = `${Math.min(x, window.innerWidth - menu.offsetWidth - 8)}px`;
|
||||||
|
menu.style.top = `${Math.min(y, window.innerHeight - menu.offsetHeight - 8)}px`;
|
||||||
|
menu.querySelector('button')?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
function closeContextMenu() { $('#context-menu').hidden = true; }
|
||||||
|
document.addEventListener('mousedown', event => { if (event.button === 0 && !event.target.closest('#context-menu')) closeContextMenu(); });
|
||||||
|
window.addEventListener('blur', closeContextMenu);
|
||||||
|
window.addEventListener('resize', closeContextMenu);
|
||||||
|
$('.canvas-scroll').addEventListener('scroll', closeContextMenu, { passive: true });
|
||||||
|
$('#context-menu').addEventListener('keydown', event => {
|
||||||
|
const items = [...event.currentTarget.querySelectorAll('button')];
|
||||||
|
const index = items.indexOf(document.activeElement);
|
||||||
|
if (event.key === 'ArrowDown') { event.preventDefault(); items[(index + 1) % items.length]?.focus(); }
|
||||||
|
if (event.key === 'ArrowUp') { event.preventDefault(); items[(index - 1 + items.length) % items.length]?.focus(); }
|
||||||
|
if (event.key === 'Escape') { event.stopPropagation(); closeContextMenu(); $('.canvas-scroll').focus({ preventScroll: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
|
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
|
||||||
$('#delete').addEventListener('click', () => actions.delete(selected));
|
$('#delete').addEventListener('click', () => actions.delete(selected));
|
||||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||||
@@ -1208,7 +1341,6 @@ $('#library').addEventListener('dragstart', event => {
|
|||||||
});
|
});
|
||||||
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
||||||
|
|
||||||
$('#select-page').addEventListener('click', () => choose(null));
|
|
||||||
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||||||
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||||||
$('#save').addEventListener('click', () => save());
|
$('#save').addEventListener('click', () => save());
|
||||||
@@ -1259,66 +1391,37 @@ function setViewport(name) {
|
|||||||
}
|
}
|
||||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
||||||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||||
// Panel sizes are workspace preferences, independent of document history and publication.
|
/** Whether a key press belongs to a field rather than to the page. */
|
||||||
const panelSizes = { left: 280, right: 320 };
|
const inField = target => ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
|
||||||
try {
|
// One handler for the editor window and for the preview document: a key pressed while the
|
||||||
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
|
// preview has the focus - after a click into it - must mean the same thing.
|
||||||
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
|
function onKey(event) {
|
||||||
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
|
if (!history || busy) return;
|
||||||
const panelMinimum = { left: 240, right: 280 };
|
const key = event.key.toLowerCase();
|
||||||
function layoutPanels() {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
let left = Math.max(240, Math.min(560, panelSizes.left));
|
if (key === 's') { event.preventDefault(); save(); }
|
||||||
let right = Math.max(280, Math.min(560, panelSizes.right));
|
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
||||||
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
|
if (inField(event.target)) return;
|
||||||
const excess = Math.max(0, left + right - budget);
|
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||||
const spare = left + right - 520;
|
return;
|
||||||
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
|
}
|
||||||
for (const [side, size] of Object.entries({ left, right })) {
|
if (event.key === 'Escape') {
|
||||||
root.style.setProperty(`--panel-${side}`, `${size}px`);
|
if (!$('#context-menu').hidden) { closeContextMenu(); return; }
|
||||||
const handle = $(`[data-resize="${side}"]`);
|
if (document.querySelector('dialog[open]') || inlineEdit) return;
|
||||||
handle.setAttribute('aria-valuenow', String(Math.round(size)));
|
if (pendingInsert) { setPendingInsert(null); if (selected) setView('element'); return; }
|
||||||
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
|
if (selected) { choose(null); $('.canvas-scroll').focus({ preventScroll: true }); }
|
||||||
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
|
return;
|
||||||
|
}
|
||||||
|
// Delete removes the selected element - but never while a field or the preview text is
|
||||||
|
// being typed in, and never something the sidebar buttons would refuse.
|
||||||
|
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
|
||||||
|
event.preventDefault(); actions.delete(selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
|
window.addEventListener('keydown', onKey);
|
||||||
for (const handle of document.querySelectorAll('[data-resize]')) {
|
|
||||||
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
|
|
||||||
let drag = null;
|
|
||||||
const setSize = value => {
|
|
||||||
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
|
|
||||||
layoutPanels();
|
|
||||||
};
|
|
||||||
handle.addEventListener('pointerdown', event => {
|
|
||||||
if (event.button !== 0) return;
|
|
||||||
event.preventDefault(); handle.focus();
|
|
||||||
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
|
|
||||||
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
|
|
||||||
});
|
|
||||||
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
|
|
||||||
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
|
|
||||||
handle.addEventListener('pointerup', stop);
|
|
||||||
handle.addEventListener('pointercancel', stop);
|
|
||||||
handle.addEventListener('lostpointercapture', stop);
|
|
||||||
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
|
|
||||||
handle.addEventListener('keydown', event => {
|
|
||||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
const current = Number(handle.getAttribute('aria-valuenow'));
|
|
||||||
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
|
|
||||||
persistPanels();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
|
|
||||||
layoutPanels();
|
|
||||||
window.addEventListener('keydown', event => {
|
|
||||||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
|
||||||
const key = event.key.toLowerCase();
|
|
||||||
if (key === 's') { event.preventDefault(); save(); }
|
|
||||||
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
|
||||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
|
|
||||||
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
|
||||||
});
|
|
||||||
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||||
buildLibrary();
|
buildLibrary();
|
||||||
|
// The workspace is a fixed frame; a scroll position restored from an earlier visit would push
|
||||||
|
// the header out of view.
|
||||||
|
window.scrollTo(0, 0);
|
||||||
load();
|
load();
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||||
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}}
|
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
|
||||||
|
|
||||||
|
|
||||||
/* Hover effects remain CSS; entrance effects are observed by motion.js. */
|
/* Hover effects remain CSS; entrance effects are observed by motion.js. They play for everyone
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
unless the website chooses to respect the browser preference - see the override at the end. */
|
||||||
.fx-hover-sink { transition: transform .2s ease }
|
.fx-hover-sink { transition: transform .2s ease }
|
||||||
.fx-hover-sink:hover { transform: translateY(3px) }
|
.fx-hover-sink:hover { transform: translateY(3px) }
|
||||||
.fx-hover-glow { transition: box-shadow .2s ease }
|
.fx-hover-glow { transition: box-shadow .2s ease }
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
|
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
|
||||||
.fx-hover-grow-border { transition: box-shadow .2s ease }
|
.fx-hover-grow-border { transition: box-shadow .2s ease }
|
||||||
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
|
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
|
||||||
}
|
|
||||||
|
|
||||||
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
|
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
|
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
|
||||||
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
|
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
|
||||||
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
|
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
|
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
|
||||||
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
|
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
|
||||||
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
|
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
|
||||||
@@ -44,4 +42,15 @@
|
|||||||
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
|
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
|
||||||
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
|
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
|
||||||
.button:hover { transform: translateY(-2px) }
|
.button:hover { transform: translateY(-2px) }
|
||||||
|
|
||||||
|
/* Die Website kann die Browservorgabe „Bewegung reduzieren" beachten (Einstellungen → Bewegung).
|
||||||
|
Dann trägt <html> die Klasse motion-respect, und die Hover-Fahrten fallen weg - Farben und
|
||||||
|
Schatten bleiben, nur nichts bewegt sich mehr. Ohne die Klasse spielt alles, wie bei Flatsome. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html.motion-respect :is(.fx-hover-lift, .fx-hover-zoom img, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-image img, .post-nav-link, .button) { transition: none }
|
||||||
|
html.motion-respect :is(.fx-hover-lift, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-nav-link, .button):hover,
|
||||||
|
html.motion-respect :is(.fx-hover-lift, .post-card):focus-within,
|
||||||
|
html.motion-respect .post-nav-link:focus-visible,
|
||||||
|
html.motion-respect .fx-hover-zoom:hover img, html.motion-respect .fx-hover-zoom:focus-within img,
|
||||||
|
html.motion-respect .post-card:hover .post-image img, html.motion-respect .post-card:focus-within .post-image img { transform: none }
|
||||||
}
|
}
|
||||||
+66
-14
@@ -1,35 +1,80 @@
|
|||||||
/** Visible content is the baseline. Enhance only when it enters the viewport. */
|
/**
|
||||||
|
* Entrance effects.
|
||||||
|
*
|
||||||
|
* Visible content is the baseline: without this script every block simply stands on the page.
|
||||||
|
* With it, a block that carries an entrance class waits out of sight until it scrolls into
|
||||||
|
* view, then comes in - the way the Flatsome animations behave.
|
||||||
|
*
|
||||||
|
* Whether the browser preference "reduce motion" counts is the website's decision, made under
|
||||||
|
* Einstellungen and delivered as a class on <html>. Without `motion-respect` the effects play
|
||||||
|
* for everyone; many systems report the preference without anyone having chosen it, and a site
|
||||||
|
* whose animations never show is not what the person building it asked for.
|
||||||
|
*/
|
||||||
export function initMotion(doc = document) {
|
export function initMotion(doc = document) {
|
||||||
const view = doc.defaultView;
|
const view = doc.defaultView;
|
||||||
|
const html = doc.documentElement;
|
||||||
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const reduced = () => html.classList.contains('motion-respect') && preference.matches;
|
||||||
const animations = new Set();
|
const animations = new Set();
|
||||||
const seen = new WeakSet();
|
const seen = new WeakSet();
|
||||||
|
// Blocks hidden while they wait. Kept so they can be shown again the moment the effects are
|
||||||
|
// switched off - a block must never stay invisible because of a preference.
|
||||||
|
const hidden = new Set();
|
||||||
const start = {
|
const start = {
|
||||||
fade: { opacity: .2 }, rise: { transform: 'translateY(28px)' }, sink: { transform: 'translateY(-28px)' },
|
fade: { opacity: 0 },
|
||||||
left: { transform: 'translateX(-36px)' }, right: { transform: 'translateX(36px)' },
|
rise: { opacity: 0, transform: 'translateY(40px)' },
|
||||||
zoom: { transform: 'scale(.92)' }, flip: { transform: 'perspective(800px) rotateX(-16deg)' }, blur: { filter: 'blur(6px)' },
|
sink: { opacity: 0, transform: 'translateY(-40px)' },
|
||||||
|
left: { opacity: 0, transform: 'translateX(-56px)' },
|
||||||
|
right: { opacity: 0, transform: 'translateX(56px)' },
|
||||||
|
zoom: { opacity: 0, transform: 'scale(.88)' },
|
||||||
|
flip: { opacity: 0, transform: 'perspective(800px) rotateX(-18deg)' },
|
||||||
|
blur: { opacity: 0, filter: 'blur(8px)' },
|
||||||
};
|
};
|
||||||
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(node => Object.keys(start).some(name => node.classList.contains(`fx-entrance-${name}`)));
|
const nameOf = node => Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
|
||||||
|
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(nameOf);
|
||||||
|
const hide = node => { if (!seen.has(node) && !hidden.has(node)) { hidden.add(node); node.style.opacity = '0'; } };
|
||||||
|
const unhide = node => { if (hidden.delete(node)) node.style.opacity = ''; };
|
||||||
const show = node => {
|
const show = node => {
|
||||||
if (seen.has(node) || preference.matches) return;
|
if (seen.has(node) || reduced()) return;
|
||||||
seen.add(node);
|
seen.add(node);
|
||||||
const name = Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
|
unhide(node);
|
||||||
|
const name = nameOf(node);
|
||||||
const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0;
|
const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0;
|
||||||
// Fade itself is optional; direction, zoom and flip keep text at full contrast.
|
// The block's own transform and filter, if any, are the end state - the entrance is
|
||||||
|
// composed in front of them rather than replacing them.
|
||||||
const style = view.getComputedStyle(node);
|
const style = view.getComputedStyle(node);
|
||||||
const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
|
const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
|
||||||
const first = { ...start[name] };
|
const first = { ...start[name] };
|
||||||
if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform;
|
if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform;
|
||||||
const animation = node.animate([first, finish], { duration: 650, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'none' });
|
if (first.filter && finish.filter !== 'none') first.filter += ' ' + finish.filter;
|
||||||
animations.add(animation); animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
|
// `backwards` holds the start state through the delay, so a delayed block does not flash
|
||||||
|
// up and vanish before its turn.
|
||||||
|
const animation = node.animate([first, finish], { duration: 750, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'backwards' });
|
||||||
|
animations.add(animation);
|
||||||
|
animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
|
||||||
};
|
};
|
||||||
|
/** A block the reader has already scrolled past: shown at once, no entrance for nobody. */
|
||||||
|
const passed = node => { seen.add(node); unhide(node); observer.unobserve(node); };
|
||||||
const observer = new view.IntersectionObserver(entries => {
|
const observer = new view.IntersectionObserver(entries => {
|
||||||
for (const entry of entries) if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
|
for (const entry of entries) {
|
||||||
}, { threshold: 0, rootMargin: '0px 0px -24px 0px' });
|
if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
|
||||||
|
else if (entry.boundingClientRect.bottom < 0) passed(entry.target);
|
||||||
|
else hide(entry.target);
|
||||||
|
}
|
||||||
|
}, { threshold: 0, rootMargin: '0px 0px -40px 0px' });
|
||||||
|
// A jump - an anchor link, a fast scroll, a restored position - can carry the reader past a
|
||||||
|
// block without it ever intersecting. Nothing that is above the viewport may stay hidden.
|
||||||
|
let sweeping = false;
|
||||||
|
const sweep = () => {
|
||||||
|
sweeping = false;
|
||||||
|
for (const node of [...hidden]) if (node.getBoundingClientRect().bottom < 0) passed(node);
|
||||||
|
};
|
||||||
|
const onScroll = () => { if (!sweeping) { sweeping = true; view.requestAnimationFrame(sweep); } };
|
||||||
const syncPreference = () => {
|
const syncPreference = () => {
|
||||||
if (preference.matches) {
|
if (reduced()) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
animations.forEach(animation => animation.cancel());
|
animations.forEach(animation => animation.cancel());
|
||||||
|
[...hidden].forEach(unhide);
|
||||||
} else {
|
} else {
|
||||||
// Reduction may have been active at startup or enabled during this visit.
|
// Reduction may have been active at startup or enabled during this visit.
|
||||||
// Resume observing unseen content when movement is allowed again.
|
// Resume observing unseen content when movement is allowed again.
|
||||||
@@ -37,8 +82,15 @@ export function initMotion(doc = document) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
preference.addEventListener('change', syncPreference);
|
preference.addEventListener('change', syncPreference);
|
||||||
|
view.addEventListener('scroll', onScroll, { passive: true });
|
||||||
syncPreference();
|
syncPreference();
|
||||||
return () => { observer.disconnect(); animations.forEach(animation => animation.cancel()); preference.removeEventListener('change', syncPreference); };
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
animations.forEach(animation => animation.cancel());
|
||||||
|
[...hidden].forEach(unhide);
|
||||||
|
preference.removeEventListener('change', syncPreference);
|
||||||
|
view.removeEventListener('scroll', onScroll);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!document.querySelector('#editor')) initMotion();
|
if (!document.querySelector('#editor')) initMotion();
|
||||||
@@ -364,10 +364,10 @@ body.consent-open{padding-bottom:260px}
|
|||||||
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
|
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
|
||||||
body.consent-open .scroll-top{display:none}
|
body.consent-open .scroll-top{display:none}
|
||||||
@supports (animation-timeline: scroll()){
|
@supports (animation-timeline: scroll()){
|
||||||
@media (prefers-reduced-motion: no-preference){
|
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
|
||||||
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
|
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
|
||||||
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
|
/* Beachtet die Website die Browservorgabe (Einstellungen → Bewegung), steht er von Anfang an da. */
|
||||||
}
|
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{animation:none}}
|
||||||
}
|
}
|
||||||
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
|
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
|
||||||
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
|
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export function initSliders(doc = document) {
|
|||||||
slider.dataset.enhanced = 'true';
|
slider.dataset.enhanced = 'true';
|
||||||
const events = new view.AbortController(), signal = events.signal;
|
const events = new view.AbortController(), signal = events.signal;
|
||||||
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
// The browser preference counts only when the website says so (Einstellungen → Bewegung).
|
||||||
|
const reduced = () => doc.documentElement.classList.contains('motion-respect') && preference.matches;
|
||||||
const seconds = Number(slider.dataset.autoplay || 0);
|
const seconds = Number(slider.dataset.autoplay || 0);
|
||||||
let playing = seconds >= 3 && !preference.matches, hover = false, visible = false, timer;
|
let playing = seconds >= 3 && !reduced(), hover = false, visible = false, timer;
|
||||||
const controls = doc.createElement('div'); controls.className = 'slider-controls';
|
const controls = doc.createElement('div'); controls.className = 'slider-controls';
|
||||||
for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name);
|
for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name);
|
||||||
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
|
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
|
||||||
@@ -28,7 +30,7 @@ export function initSliders(doc = document) {
|
|||||||
};
|
};
|
||||||
const go = index => {
|
const go = index => {
|
||||||
if (!available().includes(index)) return;
|
if (!available().includes(index)) return;
|
||||||
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: preference.matches ? 'instant' : 'smooth' });
|
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: reduced() ? 'instant' : 'smooth' });
|
||||||
};
|
};
|
||||||
const step = direction => {
|
const step = direction => {
|
||||||
const visible = available(), position = visible.indexOf(active());
|
const visible = available(), position = visible.indexOf(active());
|
||||||
@@ -58,12 +60,12 @@ export function initSliders(doc = document) {
|
|||||||
if (play) {
|
if (play) {
|
||||||
play.textContent = playing ? 'Pause' : 'Wiedergabe';
|
play.textContent = playing ? 'Pause' : 'Wiedergabe';
|
||||||
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
|
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
|
||||||
play.disabled = preference.matches; play.title = preference.matches ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
|
play.disabled = reduced(); play.title = reduced() ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (playing && !preference.matches) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
|
if (playing && !reduced()) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
|
||||||
};
|
};
|
||||||
const pause = () => { playing = false; update(); clearTimeout(timer); };
|
const pause = () => { playing = false; update(); clearTimeout(timer); };
|
||||||
slider.after(controls); slider.classList.add('slider-controlled'); update();
|
slider.after(controls); slider.classList.add('slider-controlled'); update();
|
||||||
@@ -78,7 +80,7 @@ export function initSliders(doc = document) {
|
|||||||
}, { signal });
|
}, { signal });
|
||||||
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider);
|
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider);
|
||||||
const resize = new view.ResizeObserver(update); resize.observe(slider);
|
const resize = new view.ResizeObserver(update); resize.observe(slider);
|
||||||
preference.addEventListener('change', () => { if (preference.matches) pause(); update(); }, { signal });
|
preference.addEventListener('change', () => { if (reduced()) pause(); update(); }, { signal });
|
||||||
schedule();
|
schedule();
|
||||||
cleanups.push(() => { clearTimeout(timer); events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
cleanups.push(() => { clearTimeout(timer); events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
||||||
});
|
});
|
||||||
|
|||||||
+61
-48
@@ -55,6 +55,10 @@ try {
|
|||||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
|
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
|
||||||
checks.push({ name, violations: 0 });
|
checks.push({ name, violations: 0 });
|
||||||
};
|
};
|
||||||
|
const tab = name => page.getByRole('tab', { name, exact: true }).click();
|
||||||
|
const treeSelect = async id => { await tab('Struktur'); await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`#structure [data-edit-block="${id}"]`).click(); };
|
||||||
|
const treeToggle = async id => { await tab('Struktur'); await page.locator(`#structure [data-toggle="${id}"]`).click(); };
|
||||||
|
const addFromLibrary = async selector => { await tab('Elemente'); await page.locator(`#library ${selector}`).click(); };
|
||||||
await page.goto(origin + '/admin');
|
await page.goto(origin + '/admin');
|
||||||
assert.equal(new URL(page.url()).pathname, '/admin/login');
|
assert.equal(new URL(page.url()).pathname, '/admin/login');
|
||||||
await axe('login');
|
await axe('login');
|
||||||
@@ -104,31 +108,22 @@ try {
|
|||||||
const frame = page.frameLocator('#preview');
|
const frame = page.frameLocator('#preview');
|
||||||
await frame.locator('h1').waitFor();
|
await frame.locator('h1').waitFor();
|
||||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
// Flexible panels, contextual fields and a root insertion from a deeply selected element.
|
// One sidebar, contextual fields and a root insertion from a deeply selected element.
|
||||||
const leftHandle = page.locator('[data-resize="left"]'), rightHandle = page.locator('[data-resize="right"]');
|
|
||||||
await leftHandle.focus(); await page.keyboard.press('ArrowRight');
|
|
||||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
|
||||||
const rightBox = await rightHandle.boundingBox();
|
|
||||||
await page.mouse.move(rightBox.x + 3, rightBox.y + 100); await page.mouse.down();
|
|
||||||
await page.mouse.move(rightBox.x - 57, rightBox.y + 100, { steps: 6 }); await page.mouse.up();
|
|
||||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await frame.locator('h1').waitFor();
|
await frame.locator('h1').waitFor();
|
||||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
|
||||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
|
||||||
for (const width of [1920, 1100, 850, 390]) {
|
for (const width of [1920, 1100, 850, 390]) {
|
||||||
await page.setViewportSize({ width, height: 1100 });
|
await page.setViewportSize({ width, height: 1100 });
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
|
||||||
for (const selector of ['.left-panel', '.right-panel', '.library-buttons']) {
|
for (const selector of ['.sidebar', '.sidebar-body', '.library-buttons']) {
|
||||||
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
|
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||||
await frame.locator('h1').click();
|
await frame.locator('h1').click();
|
||||||
const sectionCount = await frame.locator('main > .block-section').count();
|
const sectionCount = await frame.locator('main > .block-section').count();
|
||||||
await page.locator('#add-root-section').click();
|
await addFromLibrary('[data-add="section"]');
|
||||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||||
await page.locator('#undo').click();
|
await page.locator('#undo').click();
|
||||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
|
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
|
||||||
@@ -136,7 +131,7 @@ try {
|
|||||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||||
await page.locator('#undo').click();
|
await page.locator('#undo').click();
|
||||||
await frame.locator('h1').click();
|
await frame.locator('h1').click();
|
||||||
await page.locator('#library [data-add="image"]').click();
|
await addFromLibrary('[data-add="image"]');
|
||||||
await page.locator('[data-panel="design"]').click();
|
await page.locator('[data-panel="design"]').click();
|
||||||
assert.equal(await page.locator('#style-fontSize').count(), 0);
|
assert.equal(await page.locator('#style-fontSize').count(), 0);
|
||||||
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
|
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
|
||||||
@@ -169,17 +164,15 @@ try {
|
|||||||
await page.locator('#undo').click();
|
await page.locator('#undo').click();
|
||||||
await page.locator('#undo').click();
|
await page.locator('#undo').click();
|
||||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
|
await page.locator('.sidebar-body').evaluate(el => el.scrollTop = 0);
|
||||||
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
|
|
||||||
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
|
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
|
||||||
await leftHandle.dblclick(); await rightHandle.dblclick();
|
|
||||||
await page.locator('[data-panel="content"]').click();
|
await page.locator('[data-panel="content"]').click();
|
||||||
checks.push({ name: 'resizable persistent panels, root sections, contextual image/text properties and full text width', passed: true });
|
checks.push({ name: 'one sidebar, root sections, contextual image/text properties and full text width', passed: true });
|
||||||
|
|
||||||
// A layout starts empty: it must be visible, reachable and editable before it has any text.
|
// A layout starts empty: it must be visible, reachable and editable before it has any text.
|
||||||
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
await frame.locator('h1').click();
|
await frame.locator('h1').click();
|
||||||
await page.locator('#add-root-section').click();
|
await addFromLibrary('[data-add="section"]');
|
||||||
await previewReady();
|
await previewReady();
|
||||||
const emptySection = frame.locator('main > .editor-empty-container');
|
const emptySection = frame.locator('main > .editor-empty-container');
|
||||||
await emptySection.waitFor();
|
await emptySection.waitFor();
|
||||||
@@ -197,10 +190,11 @@ try {
|
|||||||
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
|
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
|
||||||
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
|
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
|
||||||
await page.locator('#library-search').fill('Text');
|
await page.locator('#library-search').fill('Text');
|
||||||
await page.locator('#library [data-add="text"]').click();
|
await addFromLibrary('[data-add="text"]');
|
||||||
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
|
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
|
||||||
await previewReady();
|
await previewReady();
|
||||||
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
|
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
|
||||||
|
await tab('Elemente');
|
||||||
await page.locator('#library-search').fill('');
|
await page.locator('#library-search').fill('');
|
||||||
const dragSource = page.locator('#library [data-add="button"]');
|
const dragSource = page.locator('#library [data-add="button"]');
|
||||||
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
||||||
@@ -249,6 +243,7 @@ try {
|
|||||||
const destination = frame.locator(`#${secondColumnId}`);
|
const destination = frame.locator(`#${secondColumnId}`);
|
||||||
await destination.scrollIntoViewIfNeeded();
|
await destination.scrollIntoViewIfNeeded();
|
||||||
const box = await destination.boundingBox();
|
const box = await destination.boundingBox();
|
||||||
|
await tab('Struktur');
|
||||||
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
|
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
|
||||||
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
|
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
|
||||||
});
|
});
|
||||||
@@ -268,7 +263,7 @@ try {
|
|||||||
assert.ok(publishedEmpty.includes(newSectionId));
|
assert.ok(publishedEmpty.includes(newSectionId));
|
||||||
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
|
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
|
||||||
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
|
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
|
||||||
await page.locator(`#structure [data-block-id="${newSectionId}"]`).click();
|
await treeSelect(newSectionId);
|
||||||
await page.locator('#delete').click();
|
await page.locator('#delete').click();
|
||||||
await page.locator('#save').click();
|
await page.locator('#save').click();
|
||||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||||
@@ -306,7 +301,7 @@ try {
|
|||||||
const headingId = await inPreview('h1');
|
const headingId = await inPreview('h1');
|
||||||
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
|
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
|
||||||
await page.locator('#insert-hint').waitFor({ state: 'visible' });
|
await page.locator('#insert-hint').waitFor({ state: 'visible' });
|
||||||
await page.locator('#library button[data-add="text"]').click();
|
await addFromLibrary('button[data-add="text"]');
|
||||||
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
|
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
|
||||||
assert.equal(await page.locator('#insert-hint').isVisible(), false);
|
assert.equal(await page.locator('#insert-hint').isVisible(), false);
|
||||||
await page.waitForFunction(id => {
|
await page.waitForFunction(id => {
|
||||||
@@ -318,6 +313,7 @@ try {
|
|||||||
// Ziehen aus dem Werkzeugkasten auf die Seite.
|
// Ziehen aus dem Werkzeugkasten auf die Seite.
|
||||||
await frame.locator('h1').waitFor();
|
await frame.locator('h1').waitFor();
|
||||||
const beforeButtons = await page.locator('#structure [data-block-id]').count();
|
const beforeButtons = await page.locator('#structure [data-block-id]').count();
|
||||||
|
await tab('Elemente');
|
||||||
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
|
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
|
||||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
|
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
|
||||||
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
|
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
|
||||||
@@ -351,15 +347,16 @@ try {
|
|||||||
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
|
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
|
||||||
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||||
// einem anderen Dokument.
|
// einem anderen Dokument.
|
||||||
|
await tab('Struktur');
|
||||||
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
||||||
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
|
assert.equal(await page.locator('#properties-title').textContent(), 'Sektion');
|
||||||
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
||||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
|
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
|
||||||
|
|
||||||
// Und in der Zeile wird die Spalte moeglich.
|
// Und in der Zeile wird die Spalte moeglich.
|
||||||
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
||||||
await page.locator('#library button[data-row="3"]').click();
|
await addFromLibrary('button[data-row="3"]');
|
||||||
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
|
assert.equal(await page.locator('#properties-title').textContent(), 'Zeile');
|
||||||
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
|
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
|
||||||
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
|
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
|
||||||
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
|
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
|
||||||
@@ -367,6 +364,7 @@ try {
|
|||||||
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
|
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
|
||||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
|
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
|
||||||
|
await tab('Elemente');
|
||||||
const mitBild = await page.locator('#library button .library-preview svg').count();
|
const mitBild = await page.locator('#library button .library-preview svg').count();
|
||||||
const knoepfe = await page.locator('#library button').count();
|
const knoepfe = await page.locator('#library button').count();
|
||||||
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
|
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
|
||||||
@@ -431,6 +429,7 @@ try {
|
|||||||
checks.push({ name: 'preview never shows unstyled content', passed: true });
|
checks.push({ name: 'preview never shows unstyled content', passed: true });
|
||||||
console.log('Preview edits and history passed.');
|
console.log('Preview edits and history passed.');
|
||||||
|
|
||||||
|
await tab('Struktur');
|
||||||
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
||||||
assert.equal(await page.locator('#structure .tree-item').count(), 3);
|
assert.equal(await page.locator('#structure .tree-item').count(), 3);
|
||||||
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
|
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
|
||||||
@@ -440,6 +439,8 @@ try {
|
|||||||
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
|
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
|
||||||
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
|
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
|
||||||
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
|
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
|
||||||
|
// Enter in the tree only selects; editing is the button next to the selected row.
|
||||||
|
await page.locator('#structure [data-edit-block="headline"]').click();
|
||||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
|
|
||||||
const frameValue = async (selector, property, expected) => {
|
const frameValue = async (selector, property, expected) => {
|
||||||
@@ -495,7 +496,7 @@ try {
|
|||||||
assert.match(await contrast.textContent(), /Kontrast \d/, 'The panel reports a contrast ratio.');
|
assert.match(await contrast.textContent(), /Kontrast \d/, 'The panel reports a contrast ratio.');
|
||||||
|
|
||||||
// Per side spacing on the section, and a hover rule that also covers keyboard focus.
|
// Per side spacing on the section, and a hover rule that also covers keyboard focus.
|
||||||
await page.locator('#structure [data-block-id="hero"]').click();
|
await treeSelect('hero');
|
||||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
await setLength('style-padding-top', 96);
|
await setLength('style-padding-top', 96);
|
||||||
await frameValue('#hero', 'paddingTop', '96px');
|
await frameValue('#hero', 'paddingTop', '96px');
|
||||||
@@ -539,7 +540,7 @@ try {
|
|||||||
await frameValue('#hero', 'paddingTop', '640px');
|
await frameValue('#hero', 'paddingTop', '640px');
|
||||||
|
|
||||||
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
|
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
|
||||||
await page.locator('#structure [data-block-id="headline"]').click();
|
await treeSelect('headline');
|
||||||
await openGroup('Typografie');
|
await openGroup('Typografie');
|
||||||
const scrub = page.locator('#style-fontSize');
|
const scrub = page.locator('#style-fontSize');
|
||||||
await scrub.scrollIntoViewIfNeeded();
|
await scrub.scrollIntoViewIfNeeded();
|
||||||
@@ -558,7 +559,7 @@ try {
|
|||||||
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
|
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
|
||||||
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
|
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
|
||||||
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
|
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
|
||||||
await page.locator('#structure [data-block-id="hero"]').click();
|
await treeSelect('hero');
|
||||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
await page.locator('#style-padding-unit').selectOption('px');
|
await page.locator('#style-padding-unit').selectOption('px');
|
||||||
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
|
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
|
||||||
@@ -587,6 +588,7 @@ try {
|
|||||||
});
|
});
|
||||||
checks.push({ name: 'free styles: units, inheritance, single reset, palette colour, per-side spacing, hover rule', passed: true });
|
checks.push({ name: 'free styles: units, inheritance, single reset, palette colour, per-side spacing, hover rule', passed: true });
|
||||||
|
|
||||||
|
await tab('Elemente');
|
||||||
await page.locator('[data-preset="columns"]').click();
|
await page.locator('[data-preset="columns"]').click();
|
||||||
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
|
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
|
||||||
const presetRow = `#${presetSection} .block-row`;
|
const presetRow = `#${presetSection} .block-row`;
|
||||||
@@ -607,12 +609,12 @@ try {
|
|||||||
await frame.locator(presetRow).waitFor({ state: 'detached' });
|
await frame.locator(presetRow).waitFor({ state: 'detached' });
|
||||||
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
|
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
|
||||||
await columnCount(2);
|
await columnCount(2);
|
||||||
await page.locator('#structure [data-block-id="headline"]').click();
|
await treeSelect('headline');
|
||||||
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
|
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
|
||||||
checks.push({ name: 'keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset', passed: true });
|
checks.push({ name: 'keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset', passed: true });
|
||||||
console.log('Responsive design, tree tools and presets passed.');
|
console.log('Responsive design, tree tools and presets passed.');
|
||||||
|
|
||||||
await page.locator('#library [data-add="gallery"]').click();
|
await addFromLibrary('[data-add="gallery"]');
|
||||||
const galleryId = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
|
const galleryId = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
|
||||||
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
|
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
|
||||||
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
|
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
|
||||||
@@ -639,12 +641,12 @@ try {
|
|||||||
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
|
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
|
||||||
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
|
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
|
||||||
const imageId = await frame.locator(`#${galleryId} figure`).first().getAttribute('id');
|
const imageId = await frame.locator(`#${galleryId} figure`).first().getAttribute('id');
|
||||||
await page.locator(`#structure [data-block-id="${imageId}"]`).click();
|
await treeSelect(imageId);
|
||||||
await page.getByLabel('Bildunterschrift', { exact: true }).fill('Ein Ausblick zum Durchatmen.');
|
await page.getByLabel('Bildunterschrift', { exact: true }).fill('Ein Ausblick zum Durchatmen.');
|
||||||
await frame.getByText('Ein Ausblick zum Durchatmen.', { exact: true }).waitFor();
|
await frame.getByText('Ein Ausblick zum Durchatmen.', { exact: true }).waitFor();
|
||||||
await page.getByRole('button', { name: 'Nach unten verschieben', exact: true }).click();
|
await page.getByRole('button', { name: 'Nach unten verschieben', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
await page.locator(`#structure [data-block-id="${galleryId}"]`).click();
|
await treeSelect(galleryId);
|
||||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
await openGroup('Layout');
|
await openGroup('Layout');
|
||||||
await page.locator('#style-gridColumns').fill('2');
|
await page.locator('#style-gridColumns').fill('2');
|
||||||
@@ -661,7 +663,7 @@ try {
|
|||||||
await openGroup('Bewegung und Effekte');
|
await openGroup('Bewegung und Effekte');
|
||||||
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
|
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
|
||||||
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
|
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
|
||||||
for (const [effect, transform] of [['left', 'translateX(-36px)'], ['right', 'translateX(36px)']]) {
|
for (const [effect, transform] of [['left', 'translateX(-56px)'], ['right', 'translateX(56px)']]) {
|
||||||
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption(effect);
|
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption(effect);
|
||||||
await frame.locator(`#${galleryId}.fx-entrance-${effect}`).waitFor();
|
await frame.locator(`#${galleryId}.fx-entrance-${effect}`).waitFor();
|
||||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
@@ -672,19 +674,31 @@ try {
|
|||||||
element.animate = (frames, options) => { window.observedEntrance.push(frames); return animate(frames, options); };
|
element.animate = (frames, options) => { window.observedEntrance.push(frames); return animate(frames, options); };
|
||||||
element.scrollIntoView();
|
element.scrollIntoView();
|
||||||
}, galleryId);
|
}, galleryId);
|
||||||
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
|
||||||
await page.waitForFunction(() => window.observedEntrance.length > 0);
|
await page.waitForFunction(() => window.observedEntrance.length > 0);
|
||||||
assert.equal(await page.evaluate(() => window.observedEntrance[0][0].transform), transform);
|
assert.equal(await page.evaluate(() => window.observedEntrance[0][0].transform), transform);
|
||||||
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
|
||||||
}
|
}
|
||||||
|
// Default setting: the preview plays even when the browser asks for less motion (Flatsome mode).
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
await page.evaluate(() => { window.observedEntrance = []; });
|
await page.evaluate(() => { window.observedEntrance = []; });
|
||||||
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
|
||||||
await page.waitForTimeout(800);
|
await page.waitForFunction(() => window.observedEntrance.length > 0);
|
||||||
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback');
|
assert.equal(await page.locator('#motion-status').isVisible(), false, 'no motion note while the site ignores the preference');
|
||||||
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
|
||||||
|
// Setting "Browservorgabe beachten", as the preview would carry it: nothing plays, the note shows.
|
||||||
|
await page.evaluate(() => { document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'); window.observedEntrance = []; });
|
||||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
checks.push({ name: 'distinct left/right entrance playback and reduced motion in the sandbox', passed: true });
|
await page.waitForTimeout(200);
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.locator('#motion-status').waitFor({ state: 'visible' });
|
||||||
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback when respected');
|
||||||
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
|
||||||
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await page.locator('#motion-status').waitFor({ state: 'hidden' });
|
||||||
|
checks.push({ name: 'distinct left/right entrance playback, Flatsome-mode playback and respected reduced motion in the sandbox', passed: true });
|
||||||
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption('rise');
|
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption('rise');
|
||||||
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
|
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
|
||||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||||
@@ -698,7 +712,7 @@ try {
|
|||||||
await frameValue(`#${galleryId}`, 'animationName', 'none');
|
await frameValue(`#${galleryId}`, 'animationName', 'none');
|
||||||
await frameValue(`#${galleryId}`, 'transform', 'none');
|
await frameValue(`#${galleryId}`, 'transform', 'none');
|
||||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
await page.locator('#open-media-library').click();
|
await (await tab('Elemente'), page.locator('#open-media-library').click());
|
||||||
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
|
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
|
||||||
await page.getByRole('button', { name: 'Archivieren', exact: true }).click();
|
await page.getByRole('button', { name: 'Archivieren', exact: true }).click();
|
||||||
@@ -709,25 +723,23 @@ try {
|
|||||||
await page.getByRole('button', { name: 'Wiederherstellen', exact: true }).click();
|
await page.getByRole('button', { name: 'Wiederherstellen', exact: true }).click();
|
||||||
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
||||||
await page.locator('#structure [data-block-id="headline"]').click();
|
await treeSelect('headline');
|
||||||
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
|
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
|
||||||
checks.push({ name: 'image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects', passed: true });
|
checks.push({ name: 'image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects', passed: true });
|
||||||
console.log('Media library, gallery and effects passed.');
|
console.log('Media library, gallery and effects passed.');
|
||||||
|
|
||||||
await page.locator('#library [data-add="text"]').click();
|
await addFromLibrary('[data-add="text"]');
|
||||||
await page.getByLabel('Text', { exact: true }).fill('Dieser Absatz wurde im Editor eingefügt.');
|
await page.getByLabel('Text', { exact: true }).fill('Dieser Absatz wurde im Editor eingefügt.');
|
||||||
await page.getByRole('button', { name: 'Duplizieren', exact: true }).click();
|
await page.getByRole('button', { name: 'Duplizieren', exact: true }).click();
|
||||||
await page.getByLabel('Text', { exact: true }).fill('Das ist eine unabhängige Kopie.');
|
await page.getByLabel('Text', { exact: true }).fill('Das ist eine unabhängige Kopie.');
|
||||||
await page.getByRole('button', { name: 'Nach oben verschieben', exact: true }).click();
|
await page.getByRole('button', { name: 'Nach oben verschieben', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Löschen', exact: true }).click();
|
await page.getByRole('button', { name: 'Löschen', exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
await page.locator('#toggle-library').click();
|
await treeToggle(presetSection);
|
||||||
await page.locator(`#structure [data-toggle="${presetSection}"]`).click();
|
await treeToggle(galleryId);
|
||||||
await page.locator(`#structure [data-toggle="${galleryId}"]`).click();
|
|
||||||
await page.getByRole('button', { name: 'Text: Dieser Absatz wurde im Editor eingefügt.', exact: true }).dragTo(page.locator('#structure [data-block-id="principles"]'));
|
await page.getByRole('button', { name: 'Text: Dieser Absatz wurde im Editor eingefügt.', exact: true }).dragTo(page.locator('#structure [data-block-id="principles"]'));
|
||||||
await page.waitForFunction(() => document.querySelector('#structure [data-block-id="principles"]')?.parentElement.textContent.includes('Dieser Absatz wurde im Editor eingefügt.'));
|
await page.waitForFunction(() => document.querySelector('#structure [data-block-id="principles"]')?.parentElement.textContent.includes('Dieser Absatz wurde im Editor eingefügt.'));
|
||||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
await page.locator('#toggle-library').click();
|
|
||||||
await page.route('**/admin/api/pages/test-home/draft', route => route.abort(), { times: 1 });
|
await page.route('**/admin/api/pages/test-home/draft', route => route.abort(), { times: 1 });
|
||||||
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
|
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
|
||||||
await page.locator('#message').filter({ hasText: 'Verbindung ist unterbrochen' }).waitFor();
|
await page.locator('#message').filter({ hasText: 'Verbindung ist unterbrochen' }).waitFor();
|
||||||
@@ -881,8 +893,9 @@ try {
|
|||||||
// Back into the editor. The draft differs from the published state by now, so wait for the
|
// Back into the editor. The draft differs from the published state by now, so wait for the
|
||||||
// structure rather than for a particular save status.
|
// structure rather than for a particular save status.
|
||||||
await page.goto(origin + '/admin/pages/test-home');
|
await page.goto(origin + '/admin/pages/test-home');
|
||||||
|
await tab('Struktur');
|
||||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||||
await page.locator('#structure [data-block-id="headline"]').click();
|
await treeSelect('headline');
|
||||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
await axe('desktop editor');
|
await axe('desktop editor');
|
||||||
@@ -894,7 +907,7 @@ try {
|
|||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||||
await axe('mobile editor');
|
await axe('mobile editor');
|
||||||
await page.screenshot({ path: 'reports/editor/editor-phone.png', fullPage: true });
|
await page.screenshot({ path: 'reports/editor/editor-phone.png', fullPage: true });
|
||||||
await page.locator('#open-media-library').click();
|
await (await tab('Elemente'), page.locator('#open-media-library').click());
|
||||||
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
await axe('mobile media library');
|
await axe('mobile media library');
|
||||||
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
|
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
|
||||||
|
|||||||
+76
-29
@@ -55,10 +55,12 @@ try {
|
|||||||
assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform');
|
assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform');
|
||||||
const target = page.locator('#kontakt');
|
const target = page.locator('#kontakt');
|
||||||
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'kontakt')), false, 'Below-fold section must wait for scrolling');
|
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'kontakt')), false, 'Below-fold section must wait for scrolling');
|
||||||
|
assert.equal(await target.evaluate(node => node.style.opacity), '0', 'Below-fold block waits out of sight, as in Flatsome');
|
||||||
await target.scrollIntoViewIfNeeded();
|
await target.scrollIntoViewIfNeeded();
|
||||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'));
|
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'));
|
||||||
const calls = await page.evaluate(() => motionCalls);
|
const calls = await page.evaluate(() => motionCalls);
|
||||||
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].transform, 'translateY(28px)');
|
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].transform, 'translateY(40px)');
|
||||||
|
assert.equal(calls.find(call => call.id === 'kontakt').frames[0].opacity, 0, 'Entrance fades in from invisible, as in Flatsome');
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
await page.evaluate(() => scrollTo(0, 0)); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100);
|
await page.evaluate(() => scrollTo(0, 0)); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100);
|
||||||
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 1, 'Entrance runs once');
|
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 1, 'Entrance runs once');
|
||||||
@@ -79,37 +81,67 @@ try {
|
|||||||
const animation = node.getAnimations()[0]; animation.pause(); animation.currentTime = 150;
|
const animation = node.getAnimations()[0]; animation.pause(); animation.currentTime = 150;
|
||||||
return { transform: getComputedStyle(node).transform, frames: animation.effect.getKeyframes() };
|
return { transform: getComputedStyle(node).transform, frames: animation.effect.getKeyframes() };
|
||||||
});
|
});
|
||||||
assert.equal(actual.frames[0].transform, direction === 'left' ? 'translateX(-36px)' : 'translateX(36px)');
|
assert.equal(actual.frames[0].transform, direction === 'left' ? 'translateX(-56px)' : 'translateX(56px)');
|
||||||
const offset = Number(actual.transform.split(',')[4]);
|
const offset = Number(actual.transform.split(',')[4]);
|
||||||
assert.ok(direction === 'left' ? offset < 0 : offset > 0, 'Computed movement follows selected direction');
|
assert.ok(direction === 'left' ? offset < 0 : offset > 0, 'Computed movement follows selected direction');
|
||||||
await page.screenshot({ path: `${report}/${direction}-${width}.png` });
|
await page.screenshot({ path: `${report}/${direction}-${width}.png` });
|
||||||
await page.evaluate(() => window.stopMotionCheck());
|
await page.evaluate(() => window.stopMotionCheck());
|
||||||
}
|
}
|
||||||
|
// Default setting: the website plays regardless of the browser preference (Flatsome mode).
|
||||||
|
// Umut's Chrome reports "reduce" without anyone having chosen it; the site must still move.
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
await page.reload(); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(800);
|
await page.reload(); await target.scrollIntoViewIfNeeded();
|
||||||
assert.equal(await page.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations');
|
|
||||||
assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1');
|
|
||||||
await hoverTarget.hover(); await page.waitForTimeout(350);
|
|
||||||
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'none', 'Reduced motion suppresses hover movement');
|
|
||||||
await target.scrollIntoViewIfNeeded();
|
|
||||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
||||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(900);
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1', 'Block ends fully visible');
|
||||||
|
await hoverTarget.scrollIntoViewIfNeeded(); await hoverTarget.hover(); await page.waitForTimeout(350);
|
||||||
|
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Default setting keeps the hover lift under a reduce preference');
|
||||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
await page.waitForTimeout(100);
|
|
||||||
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 1, 'Preference changes do not replay an already seen element');
|
|
||||||
await hoverTarget.hover(); await page.waitForTimeout(350);
|
|
||||||
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Hover resumes after allowing motion');
|
|
||||||
// Also cover a page that starts normally, then suppresses motion before reaching its content.
|
|
||||||
await page.reload(); await page.waitForTimeout(800);
|
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
||||||
await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100);
|
|
||||||
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 0);
|
|
||||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
||||||
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
|
||||||
results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'left/right computed movement', 'saved hover lift', 'reduced motion', 'resume without reloading after initial and subsequent reduced motion', 'hover resumes after preference change'] });
|
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|
||||||
|
// Setting "Browservorgabe beachten": <html class="motion-respect">. Simulated by adding the
|
||||||
|
// class before any script runs, exactly where the template would print it.
|
||||||
|
const respect = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
|
||||||
|
await respect.addInitScript(() => {
|
||||||
|
window.motionCalls = [];
|
||||||
|
const animate = Element.prototype.animate;
|
||||||
|
Element.prototype.animate = function (frames, options) {
|
||||||
|
const animation = animate.call(this, frames, options);
|
||||||
|
window.motionCalls.push({ id: this.id, frames, options, scrollY, top: this.getBoundingClientRect().top });
|
||||||
|
return animation;
|
||||||
|
};
|
||||||
|
new MutationObserver((_, observer) => {
|
||||||
|
if (!document.documentElement) return;
|
||||||
|
document.documentElement.classList.add('motion-respect'); observer.disconnect();
|
||||||
|
}).observe(document, { childList: true });
|
||||||
|
});
|
||||||
|
const respectPage = await respect.newPage(); respectPage.on('pageerror', error => errors.push(error.message));
|
||||||
|
const respectTarget = respectPage.locator('#kontakt'), respectHover = respectPage.locator('#leistung-app');
|
||||||
|
await respectPage.goto(origin); await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(800);
|
||||||
|
assert.equal(await respectPage.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations when respected');
|
||||||
|
assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1');
|
||||||
|
await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350);
|
||||||
|
assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'none', 'Reduced motion suppresses hover movement when respected');
|
||||||
|
await respectTarget.scrollIntoViewIfNeeded();
|
||||||
|
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
||||||
|
await respectPage.waitForTimeout(900);
|
||||||
|
await respectPage.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await respectPage.waitForTimeout(100);
|
||||||
|
assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 1, 'Preference changes do not replay an already seen element');
|
||||||
|
await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350);
|
||||||
|
assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Hover resumes after allowing motion');
|
||||||
|
// Also cover a page that starts normally, then suppresses motion before reaching its content.
|
||||||
|
await respectPage.reload(); await respectPage.waitForTimeout(800);
|
||||||
|
await respectPage.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(100);
|
||||||
|
assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'kontakt').length), 0);
|
||||||
|
assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1', 'A waiting block is shown again when motion is reduced');
|
||||||
|
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
|
||||||
|
results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'hidden until visible', 'left/right computed movement', 'saved hover lift', 'default plays under reduce preference', 'motion-respect honours reduce', 'resume without reloading after initial and subsequent reduced motion', 'hover resumes after preference change'] });
|
||||||
|
await respect.close();
|
||||||
}
|
}
|
||||||
const noJs = await browser.newContext({ javaScriptEnabled: false });
|
const noJs = await browser.newContext({ javaScriptEnabled: false });
|
||||||
const page = await noJs.newPage(); await page.goto(origin);
|
const page = await noJs.newPage(); await page.goto(origin);
|
||||||
@@ -124,13 +156,28 @@ try {
|
|||||||
await editor.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
await editor.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||||
await editor.waitForURL(origin + '/admin');
|
await editor.waitForURL(origin + '/admin');
|
||||||
await editor.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
await editor.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||||
await editor.locator('#motion-status').waitFor({ state: 'visible' });
|
// With the default setting the note stays away: the site plays anyway, there is nothing to say.
|
||||||
await editor.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
await editor.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
await editor.screenshot({ path: `${report}/editor-motion-status.png` });
|
await editor.waitForTimeout(300);
|
||||||
await editor.emulateMedia({ reducedMotion: 'no-preference' });
|
assert.equal(await editor.locator('#motion-status').isVisible(), false, 'No motion note while the site ignores the preference');
|
||||||
await editor.locator('#motion-status').waitFor({ state: 'hidden' });
|
await editor.close();
|
||||||
await editor.emulateMedia({ reducedMotion: 'reduce' });
|
// The note appears once the site respects the preference - simulated in the preview frame.
|
||||||
await editor.locator('#motion-status').waitFor({ state: 'visible' });
|
// The preview frame carries the website's decision as a class on its <html>; set it there the
|
||||||
|
// way the template would, then let the preference change re-evaluate the note.
|
||||||
|
const editorRespect = await editorContext.newPage(); editorRespect.on('pageerror', error => errors.push(error.message));
|
||||||
|
await editorRespect.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||||
|
await editorRespect.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||||
|
await editorRespect.evaluate(() => document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'));
|
||||||
|
// Two toggles in one breath cancel out before the change event fires; give each its moment.
|
||||||
|
await editorRespect.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await editorRespect.waitForTimeout(300);
|
||||||
|
await editorRespect.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await editorRespect.locator('#motion-status').waitFor({ state: 'visible' });
|
||||||
|
await editorRespect.screenshot({ path: `${report}/editor-motion-status.png` });
|
||||||
|
await editorRespect.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await editorRespect.locator('#motion-status').waitFor({ state: 'hidden' });
|
||||||
|
await editorRespect.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await editorRespect.locator('#motion-status').waitFor({ state: 'visible' });
|
||||||
await editorContext.close();
|
await editorContext.close();
|
||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
assert.equal(await hash(), before, 'Original database unchanged');
|
assert.equal(await hash(), before, 'Original database unchanged');
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ final class SiteSettings
|
|||||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||||||
'stickyHeader' => false,
|
'stickyHeader' => false,
|
||||||
|
// Einblendungen und Hover-Bewegungen. „always" spielt sie wie Flatsome bei jedem
|
||||||
|
// Besucher ab; „respect" hält sich an die Browservorgabe „Bewegung reduzieren", die
|
||||||
|
// manche Systeme melden, ohne dass jemand sie bewusst eingestellt hat.
|
||||||
|
'motion' => 'always',
|
||||||
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
|
// Datum, Autor und Kategorie über dem Beitrag. Aus, wenn der Beitrag seinen eigenen
|
||||||
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
|
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
|
||||||
'postMetaHeader' => true,
|
'postMetaHeader' => true,
|
||||||
@@ -173,6 +177,7 @@ final class SiteSettings
|
|||||||
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
|
throw new InvalidPage('Für die Turnstile-Prüfung werden beide Schlüssel gebraucht – der öffentliche und der geheime.');
|
||||||
}
|
}
|
||||||
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
|
$values['postMetaHeader'] = (bool) ($input['postMetaHeader'] ?? false);
|
||||||
|
$values['motion'] = in_array($input['motion'] ?? '', ['always', 'respect'], true) ? $input['motion'] : 'always';
|
||||||
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
// Only comment and simple directive lines; nothing that could smuggle in a second policy.
|
||||||
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
|
// Leerzeilen bleiben erhalten (hoechstens eine am Stueck): eine robots.txt mit eigenen
|
||||||
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
|
// Regeln je Bot besteht aus Bloecken, und ohne Trennung liest die niemand mehr. Das Limit
|
||||||
|
|||||||
@@ -8,44 +8,87 @@
|
|||||||
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{# The API base decides what is edited. An area is shared, so it has no draft and no
|
{# The API base decides what is edited. An area is shared, so it has no draft and no
|
||||||
publication: saving is live, and the publish button is left out. #}
|
publication: saving is live, and the publish button is left out.
|
||||||
|
|
||||||
|
One sidebar, one canvas. The sidebar shows one thing at a time - the element library, the
|
||||||
|
page structure, the page settings or the options of the selected element - the way the
|
||||||
|
Flatsome UX Builder does it. Nothing competes for the eye. #}
|
||||||
<div id="editor" data-api="{{ area ? path('admin_partial_editor', {id: area.id}) : path('admin_document', {id: page.id}) }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
|
<div id="editor" data-api="{{ area ? path('admin_partial_editor', {id: area.id}) : path('admin_document', {id: page.id}) }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
|
||||||
<header class="editor-header">
|
<header class="editor-header">
|
||||||
<a class="back-link" href="{{ path(listRoute) }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : (page.type == 'post' ? 'Zur Beitragsübersicht' : 'Zur Seitenübersicht') }}">← <span>{{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}</span></a>
|
<a class="back-link" href="{{ path(listRoute) }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : (page.type == 'post' ? 'Zur Beitragsübersicht' : 'Zur Seitenübersicht') }}"><span aria-hidden="true">←</span> <span>{{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}</span></a>
|
||||||
<div class="editor-page-name"><h1>{{ subject.title }}</h1><span>{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}</span></div>
|
<div class="editor-page-name"><h1>{{ subject.title }}</h1><span>{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}</span></div>
|
||||||
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
|
<div class="viewport-controls" role="group" aria-label="Vorschaugröße">
|
||||||
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>{{ area ? 'Bereich speichern' : 'Entwurf speichern' }}</button>{% if not area and is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
|
<button type="button" data-viewport="desktop" aria-pressed="true" title="Desktop"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg><span>Desktop</span></button>
|
||||||
|
<button type="button" data-viewport="tablet" aria-pressed="false" title="Tablet"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Tablet</span></button>
|
||||||
|
<button type="button" data-viewport="mobile" aria-pressed="false" title="Mobil"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Mobil</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="history-actions">
|
||||||
|
<button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
|
||||||
|
<button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
|
||||||
|
<button id="preview-motion" type="button" class="icon-button" aria-pressed="false" title="Bewegung in der Vorschau abspielen" aria-label="Bewegung in der Vorschau abspielen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 4 14 8-14 8z"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="save-actions">
|
||||||
|
<span id="save-status" role="status" aria-live="polite">Wird geladen …</span>
|
||||||
|
<button id="save" class="secondary" disabled>{{ area ? 'Bereich speichern' : 'Entwurf speichern' }}</button>
|
||||||
|
{% if not area and is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}
|
||||||
|
{% if not area %}<a class="icon-button" href="{{ page.path == 'home' ? '/' : '/' ~ page.path }}" target="_blank" rel="noopener" title="Öffentliche Seite öffnen" aria-label="Öffentliche Seite öffnen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/></svg></a>{% endif %}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="message" class="editor-message" role="alert" hidden></div>
|
<div id="message" class="editor-message" role="alert" hidden></div>
|
||||||
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
||||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
<aside class="sidebar" aria-label="Bearbeitung" data-view="elements">
|
||||||
<div class="section-launch"><button id="add-root-section" class="secondary full-width" disabled>+ Neue Sektion auf Seitenebene</button><p class="field-help">Nach dem aktuellen Abschnitt, außerhalb des gewählten Inhalts.</p></div>
|
{# The top of the sidebar answers "where am I". Without a selection it is a small
|
||||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p><h3 class="preset-heading">Element hinzufügen</h3>
|
set of tabs; with one it is the element's name, the way back up and the plus. #}
|
||||||
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken. Der Werkzeugkasten zeigt an, was an der gewählten Stelle hineinpasst — was dort nicht hingehört, sagt beim Überfahren warum.</p>
|
<div class="sidebar-head">
|
||||||
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
|
<div class="sidebar-nav" role="tablist" aria-label="Seitenleiste">
|
||||||
<input id="library-search" type="search" placeholder="z. B. Galerie, Zeile, Formular …" autocomplete="off">
|
<button type="button" role="tab" data-view="elements" aria-selected="true">Elemente</button>
|
||||||
<p id="library-found" class="field-help" role="status"></p>
|
<button type="button" role="tab" data-view="structure" aria-selected="false">Struktur</button>
|
||||||
<div id="library" class="library"></div>
|
<button type="button" id="select-page" role="tab" data-view="page" aria-selected="false">Seite</button>
|
||||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
</div>
|
||||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
<div class="sidebar-element" hidden>
|
||||||
<div id="presets" class="preset-library"><button data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
<button type="button" id="go-parent" class="icon-button" title="Zurück zum übergeordneten Element" aria-label="Zurück zum übergeordneten Element"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 4-8 8 8 8"/></svg></button>
|
||||||
</div></section>
|
<div class="sidebar-element-name"><h2 id="properties-title">Element</h2><nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav></div>
|
||||||
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element auf der Seite suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
|
<button type="button" id="add-here" class="icon-button" title="Element hinzufügen" aria-label="Element hinzufügen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
||||||
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
|
<button type="button" id="close-element" class="icon-button" title="Auswahl aufheben (Escape)" aria-label="Auswahl aufheben"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
||||||
</section>
|
</div>
|
||||||
|
<div class="property-tabs" role="group" aria-label="Eigenschaftenbereich" hidden><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-body">
|
||||||
|
<section class="sidebar-view" data-view="elements" id="toolbox">
|
||||||
|
<p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p>
|
||||||
|
<label class="visually-hidden" for="library-search">Element suchen</label>
|
||||||
|
<input id="library-search" type="search" placeholder="Element suchen …" autocomplete="off">
|
||||||
|
<p id="library-found" class="field-help" role="status"></p>
|
||||||
|
<div id="library" class="library"></div>
|
||||||
|
<h3 class="library-group-title">Fertige Abschnitte</h3>
|
||||||
|
<div id="presets" class="preset-library"><button type="button" data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button type="button" data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button type="button" data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button type="button" data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
||||||
|
<button id="open-media-library" type="button" class="secondary full-width media-launch" disabled>Mediathek öffnen</button>
|
||||||
|
</section>
|
||||||
|
<section class="sidebar-view" data-view="structure" hidden>
|
||||||
|
<label class="visually-hidden" for="tree-search">Element auf der Seite suchen</label>
|
||||||
|
<input id="tree-search" type="search" placeholder="Auf der Seite suchen …">
|
||||||
|
<div class="tree-tools"><button id="expand-all" class="text-button" type="button">Alle öffnen</button><button id="collapse-all" class="text-button" type="button">Einklappen</button><button id="nest" class="text-button" type="button" title="In vorherigen Container verschachteln">⇥ Einrücken</button><button id="outdent" class="text-button" type="button" title="Eine Ebene herausheben">⇤ Ausrücken</button></div>
|
||||||
|
<nav id="structure" aria-label="Blöcke auswählen"></nav>
|
||||||
|
</section>
|
||||||
|
<section class="sidebar-view" data-view="element" hidden>
|
||||||
|
<div id="properties"><p class="muted">Inhalte werden geladen …</p></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-foot block-actions" hidden>
|
||||||
|
<button id="move-up" type="button" class="icon-button" title="Nach oben verschieben" aria-label="Nach oben verschieben"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
|
||||||
|
<button id="move-down" type="button" class="icon-button" title="Nach unten verschieben" aria-label="Nach unten verschieben"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button>
|
||||||
|
<button id="duplicate" type="button" class="secondary">Duplizieren</button>
|
||||||
|
<button id="delete" type="button" class="secondary danger-text">Löschen</button>
|
||||||
|
</div>
|
||||||
|
<p id="motion-status" class="sidebar-note" role="status" hidden>Dein Browser meldet „Bewegung reduzieren“. Die Website spielt Einblendungen trotzdem ab, solange unter Einstellungen nicht „Systemvorgabe beachten“ gewählt ist.</p>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="panel-resizer" data-resize="left" role="separator" tabindex="0" aria-label="Linke Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="240" aria-valuemax="560" aria-valuenow="280" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
|
|
||||||
<section class="canvas-panel" aria-label="Live-Vorschau">
|
<section class="canvas-panel" aria-label="Live-Vorschau">
|
||||||
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><button id="preview-motion" type="button" class="text-button" aria-pressed="false">Bewegung testen</button>{% if not area %}<a href="{{ page.path == 'home' ? '/' : '/' ~ page.path }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a>{% endif %}</div>
|
|
||||||
<p id="motion-status" class="preview-status" role="status" hidden>Dein Browser meldet „Bewegung reduzieren“. Einblendungen, Hover-Bewegungen und Slider-Automatik sind deshalb deaktiviert.</p>
|
|
||||||
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
|
|
||||||
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
|
|
||||||
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
|
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
|
||||||
|
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
|
||||||
</section>
|
</section>
|
||||||
<div class="panel-resizer" data-resize="right" role="separator" tabindex="0" aria-label="Rechte Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="560" aria-valuenow="320" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
|
|
||||||
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">{{ area ? 'Speichern aktualisiert diesen Bereich auf allen Seiten, die ihn verwenden.' : 'Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.' }}</div></aside>
|
|
||||||
</main>
|
</main>
|
||||||
|
<div id="context-menu" class="context-menu" role="menu" aria-label="Elementmenü" hidden></div>
|
||||||
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
|
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
|
||||||
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
|
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
|
||||||
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
|
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
|
||||||
|
|||||||
@@ -165,6 +165,12 @@
|
|||||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
|
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
|
||||||
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
|
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
|
||||||
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
|
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
|
||||||
|
<label for="motion">Einblendungen und Hover-Bewegungen</label>
|
||||||
|
<select id="motion" name="motion">
|
||||||
|
<option value="always"{{ settings.motion == 'always' ? ' selected' : '' }}>Immer abspielen (wie Flatsome)</option>
|
||||||
|
<option value="respect"{{ settings.motion == 'respect' ? ' selected' : '' }}>Browservorgabe „Bewegung reduzieren“ beachten</option>
|
||||||
|
</select>
|
||||||
|
<p class="field-help">Manche Browser melden „Bewegung reduzieren“, ohne dass jemand es eingestellt hat – dann bleibt bei „beachten“ jede Einblendung aus. „Immer abspielen“ verhält sich wie andere Websites; die Slider-Automatik hält an, sobald jemand sie berührt.</p>
|
||||||
<label class="check-label" for="postMetaHeader"><input id="postMetaHeader" name="postMetaHeader" type="checkbox" value="1"{{ settings.postMetaHeader ? ' checked' : '' }}> Datum, Autor und Kategorie über einem Beitrag zeigen</label>
|
<label class="check-label" for="postMetaHeader"><input id="postMetaHeader" name="postMetaHeader" type="checkbox" value="1"{{ settings.postMetaHeader ? ' checked' : '' }}> Datum, Autor und Kategorie über einem Beitrag zeigen</label>
|
||||||
<label class="check-label" for="consentBanner"><input id="consentBanner" name="consentBanner" type="checkbox" value="1"{{ settings.consentBanner ? ' checked' : '' }}> Einwilligungshinweis anzeigen</label>
|
<label class="check-label" for="consentBanner"><input id="consentBanner" name="consentBanner" type="checkbox" value="1"{{ settings.consentBanner ? ' checked' : '' }}> Einwilligungshinweis anzeigen</label>
|
||||||
<p class="field-help">Ohne eingebettete Inhalte anderer Anbieter braucht diese Website keine Einwilligung – dann ist ein Banner nur im Weg und sollte aus bleiben. Eingebettete Inhalte laden ohnehin erst nach Zustimmung, unabhängig von diesem Schalter.</p>
|
<p class="field-help">Ohne eingebettete Inhalte anderer Anbieter braucht diese Website keine Einwilligung – dann ist ein Banner nur im Weg und sollte aus bleiben. Eingebettete Inhalte laden ohnehin erst nach Zustimmung, unabhängig von diesem Schalter.</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ page.locale }}">
|
{# Ohne Klasse spielt die Website Bewegung immer ab; mit ihr gilt die Browservorgabe. #}
|
||||||
|
<html lang="{{ page.locale }}"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|||||||
+9
-8
@@ -1687,15 +1687,16 @@ final class CmsTest extends TestCase
|
|||||||
$html = $this->request('/')->getContent();
|
$html = $this->request('/')->getContent();
|
||||||
self::assertStringContainsString('<span class="post-view" aria-hidden="true">Ansehen</span>', $html);
|
self::assertStringContainsString('<span class="post-view" aria-hidden="true">Ansehen</span>', $html);
|
||||||
|
|
||||||
// Die Bewegung steht vollständig hinter der Bewegungsschranke, der Hinweis selbst nicht:
|
// Die Bewegung spielt für alle; beachtet die Website die Browservorgabe, nimmt ein
|
||||||
// wer weniger Bewegung eingestellt hat, sieht ihn trotzdem.
|
// Überschreibblock hinter der Bewegungsschranke nur die Fahrt weg, der Hinweis bleibt.
|
||||||
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
|
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
|
||||||
// Der erste Abschnitt steht vor der ersten Schranke; alles dahinter ist geschützt.
|
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $effects);
|
||||||
$guarded = explode('@media (prefers-reduced-motion: no-preference) {', $effects);
|
self::assertStringContainsString('transform: scale(1.06)', $effects);
|
||||||
$geschuetzt = implode('', array_slice($guarded, 1));
|
$schranke = explode('@media (prefers-reduced-motion: reduce) {', $effects);
|
||||||
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $geschuetzt);
|
self::assertCount(2, $schranke, 'Genau ein Überschreibblock für „Bewegung reduzieren“.');
|
||||||
self::assertStringContainsString('transform: scale(1.06)', $geschuetzt);
|
self::assertStringContainsString('html.motion-respect', $schranke[1]);
|
||||||
self::assertStringNotContainsString('transform: translateY(-8px)', $guarded[0], 'Keine Bewegung außerhalb der Schranke.');
|
self::assertStringContainsString('transform: none', $schranke[1]);
|
||||||
|
self::assertStringNotContainsString('html.motion-respect', $schranke[0], 'Ohne Schranke gilt die Klasse nicht.');
|
||||||
|
|
||||||
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
|
// Und über die Tastatur erreichbar: der Zustand hängt auch an :focus-within.
|
||||||
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||||
|
|||||||
Reference in new issue
Block a user