Rebuild the editor around one sidebar and play motion by default in 1.11.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

The editor follows the Flatsome UX Builder: dark top bar with the device
switch, one sidebar with tabs for elements, structure and page, an element
view with breadcrumbs, property tabs and actions, element cards, a context
menu in the preview and keyboard shortcuts that also work inside the preview.

Entrance and hover effects play for every visitor unless the website chooses
to respect the browser's reduced-motion preference (setting "motion", class
"motion-respect" on <html>). Waiting blocks stay hidden until they scroll into
view and are shown at once when the reader jumps past them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.1 committed 2026-09-14 16:41:04 +02:00
1 parent ebc16777d6
commit a1f8b905cd
18 files changed
+723 -433

No files matched your search

+17
View File
@@ -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`.
## 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)
„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.
+1 -1
View File
@@ -1 +1 @@
1.10.2
1.11.0
+6
View File
@@ -2,6 +2,12 @@
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)
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.
+10
View File
@@ -1,5 +1,15 @@
# 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)
**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.
+162 -187
View File
@@ -1,12 +1,15 @@
/* 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::backdrop{background:#172e2966}
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}
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.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-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.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-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}}
.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}
.editor-body{overflow:hidden}
#editor{height:100dvh;display:flex;flex-direction:column}
.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}
.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}
.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}
.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.editor-page-name>span{font-size:10px;color:var(--muted)}
.history-actions,.save-actions{display:flex;align-items:center;gap:8px}
.icon-button{font-size:21px;min-width:36px;padding:3px 9px}
.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}
.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}
.left-panel,.right-panel{background:white;overflow:auto}
.left-panel{border-right:1px solid var(--line)}
.right-panel{border-left:1px solid var(--line)}
.panel-section{padding:23px 18px;border-bottom:1px solid var(--line)}
.panel-section .eyebrow{margin-bottom:10px}
.panel-section h2{font-size:14px;margin-bottom:12px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
.panel-title{display:flex;justify-content:space-between;align-items:center}
.panel-title h2{margin-bottom:0}
.panel-title+.panel-hint{margin-top:10px}
/* ---- shell ------------------------------------------------------------------------------- */
html:has(>.editor-body){overflow:hidden;height:100%}
.editor-body{overflow:hidden;height:100dvh}
#editor{height:100dvh;display:flex;flex-direction:column;background:#e3e7e1}
.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}
.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}
.back-link:hover{color:#fff}
.editor-page-name{min-width:0;width:230px;display:grid;gap:1px}
.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}
.editor-page-name>span{font-size:11px;color:#9fb1a7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.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}
.icon-button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.editor-header .icon-button{color:#d5ded8}
.editor-header .icon-button:hover:not(:disabled){background:#ffffff1a;color:#fff}
.editor-header .icon-button[aria-pressed=true]{background:#fff;color:var(--ink)}
.viewport-controls{display:flex;gap:2px;margin:0 auto;padding:3px;background:#ffffff14;border-radius:9px}
.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}
.viewport-controls button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.viewport-controls button:hover:not(:disabled){color:#fff}
.editor-header .viewport-controls button[aria-pressed=true]{background:#fff;color:var(--ink)}
.history-actions{display:flex;gap:2px}
.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 .structure-list{padding-left:13px;border-left:1px solid #e5e9e3;margin-left:8px}
.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 .structure-list{padding-left:12px;border-left:1px solid #e5e9e3;margin-left:9px}
.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: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-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}
.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}
.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}
/* ---- properties -------------------------------------------------------------------------- */
.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: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%}
.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)}
.breadcrumbs .text-button{font-size:10px;white-space:normal}
.breadcrumbs button+button:before{content:'›';color:var(--muted)}
.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}
.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{border:1px solid var(--line);border-radius:10px;margin-top:10px;background:#fff}
.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}
.design-group>summary::-webkit-details-marker{display:none}
.design-group>summary::after{content:'+';font-size:17px;color:var(--muted)}
.design-group[open]>summary{border-bottom:1px solid var(--line)}
.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-row{margin-top:14px}
.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-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}
.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.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=number]{font-size:12px;min-height:38px}
.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 label{margin:0;font-size:10px;color:var(--muted)}
.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. */
.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}
@@ -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}
.design-side-head{margin-bottom:5px}
.design-side-head .design-group-label{margin:0}
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
.scrubbable{cursor:ew-resize}
.scrubbable:focus{cursor:text}
.design-actions{display:grid;gap:8px;margin-top:22px}
.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-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)}
@@ -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-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
.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{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:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
.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}
.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)}
/* Bringing the editor chrome in line with the new administration: same radii, same restraint,
and a few sizes that were simply too small to read comfortably. The layout is unchanged. */
.editor-header{min-height:70px}
.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}
.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}
/* --------------------------------------------------------------------------------------------
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-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
.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{background:#5c6b62;font-weight:600}
.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-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-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}
/* 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}
#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}}
/* --------------------------------------------------------------------------------------------
The toolbox, grouped the way a page is built.
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
element would land is disabled and carries the reason in its title, so the rule is learned
rather than merely enforced.
-------------------------------------------------------------------------------------------- */
.library-group + .library-group{margin-top:16px}
.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}
/* ---- narrow screens ---------------------------------------------------------------------- */
@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){
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
.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}
.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-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
}
+207 -104
View File
@@ -33,15 +33,45 @@ let renderedProperties = null;
let revealAfterPreview = null;
let previewPlayback = false, stopPlayback = () => {};
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);
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() {
stopPlayback(); stopPlayback = () => {};
const button = $('#preview-motion');
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;
if (previewPlayback && doc?.body) {
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
@@ -86,20 +116,19 @@ function controls() {
$('#undo').disabled = !ready || busy || !history.past.length;
$('#redo').disabled = !ready || busy || !history.future.length;
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) {
$('#move-up').disabled ||= current.index === 0;
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
$('#outdent').disabled ||= !current.parent;
$('#go-parent').disabled ||= !current.parent;
}
updateLibrary();
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || 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;
$('#select-page').disabled = !ready || busy;
$('#add-root-section').disabled = !ready || busy;
$('#open-media-library').disabled = !ready || busy;
$('#preview-motion').disabled = !ready || busy;
}
@@ -136,6 +165,14 @@ function renderTree() {
button.title = badge.textContent; button.append(badge);
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));
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));
if (!selected) propertyPanel = 'content';
history.mergeKey = null;
syncView();
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) {
const doc = $('#preview').contentDocument;
if (!doc) return;
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
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();
}
@@ -169,15 +222,15 @@ function renderProperties() {
const block = locate(history.document.blocks, selected)?.block;
if (!block) propertyPanel = 'content';
const context = `${selected ?? 'page'}:${propertyPanel}`;
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
if (context !== renderedProperties) $('.sidebar-body').scrollTop = 0;
renderedProperties = context;
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
const panel = $('#properties'); panel.replaceChildren();
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();
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? blockLabel(item) : 'Seite';
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)].slice(0, -1)) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
}
if (block && isPluginBlock(block)) {
@@ -188,21 +241,33 @@ function renderProperties() {
panel.append(note);
return;
}
if (block) {
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 === 'advanced') { renderAdvanced(block, panel); renderVisibility(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;
const field = (label, key, value, options = {}) => {
const container = options.container || panel;
@@ -802,6 +867,16 @@ async function updatePreview() {
const block = locate(history.document.blocks, element.id)?.block;
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());
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
const finish = () => {
@@ -814,6 +889,7 @@ async function updatePreview() {
highlight(revealAfterPreview === selected); revealAfterPreview = null;
markInsertTarget();
frame.style.visibility = '';
showMotionStatus();
playPreview();
$('#preview-status').textContent = loaded
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
@@ -853,6 +929,7 @@ async function updatePreview() {
function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
syncView();
renderTree(); renderProperties(); controls(); schedulePreview();
}
@@ -914,9 +991,9 @@ function addElement(type) {
if (busy || !history) return;
const at = pendingInsert;
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)
: 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 (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
changed();
@@ -954,12 +1031,11 @@ function setPendingInsert(target) {
$('#insert-hint').hidden = !target;
markInsertTarget();
if (target) {
if ($('#toolbox').hidden) $('#toggle-library').click();
setView('elements');
$('#library-search').value = ''; filterLibrary();
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. `;
$('#library-search').focus({ preventScroll: true });
$('#library-search').scrollIntoView({ block: 'nearest' });
}
}
@@ -998,10 +1074,10 @@ function buildLibrary() {
button.type = 'button'; button.draggable = true;
button.dataset.add = type;
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
// 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');
picture.className = 'library-preview';
// 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')) {
const type = button.dataset.add;
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)
: insertionTarget(history.document, selected, type);
: insertionTarget(history.document, pendingInsert?.parentId ?? selected, type);
button.disabled = !target;
if (!target) {
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
@@ -1074,13 +1151,22 @@ $('#library').addEventListener('click', event => {
}
addElement(button.dataset.add);
});
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
$('#add-root-section').addEventListener('click', () => {
$('#cancel-insert').addEventListener('click', () => { setPendingInsert(null); if (selected) setView('element'); });
// 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;
stopInlineEdit();
setPendingInsert(null);
selected = history.change(doc => insertBlock(doc, 'section', selected));
propertyPanel = 'content'; changed();
if (tab.dataset.view === 'page') { setView('page'); choose(null); return; }
setView(tab.dataset.view);
});
$('#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 => {
const name = event.target.closest('[data-preset]')?.dataset.preset;
@@ -1090,15 +1176,15 @@ $('#presets').addEventListener('click', event => {
$('#structure').addEventListener('click', event => {
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; }
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);
});
$('#tree-search').addEventListener('input', renderTree);
$('#toggle-library').addEventListener('click', () => {
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
$('#toggle-library').textContent = hidden ? '+' : '−';
$('#structure').addEventListener('dblclick', event => {
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
if (id && !busy) { setView('element'); choose(id); }
});
$('#tree-search').addEventListener('input', renderTree);
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
$('#collapse-all').addEventListener('click', () => {
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),
};
// ---------------------------------------------------------------------------------------------
// 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));
$('#delete').addEventListener('click', () => actions.delete(selected));
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());
$('#select-page').addEventListener('click', () => choose(null));
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
$('#save').addEventListener('click', () => save());
@@ -1259,66 +1391,37 @@ function setViewport(name) {
}
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
// Panel sizes are workspace preferences, independent of document history and publication.
const panelSizes = { left: 280, right: 320 };
try {
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
const panelMinimum = { left: 240, right: 280 };
function layoutPanels() {
let left = Math.max(240, Math.min(560, panelSizes.left));
let right = Math.max(280, Math.min(560, panelSizes.right));
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
const excess = Math.max(0, left + right - budget);
const spare = left + right - 520;
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
for (const [side, size] of Object.entries({ left, right })) {
root.style.setProperty(`--panel-${side}`, `${size}px`);
const handle = $(`[data-resize="${side}"]`);
handle.setAttribute('aria-valuenow', String(Math.round(size)));
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
/** Whether a key press belongs to a field rather than to the page. */
const inField = target => ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName) || target?.isContentEditable;
// One handler for the editor window and for the preview document: a key pressed while the
// preview has the focus - after a click into it - must mean the same thing.
function onKey(event) {
if (!history || busy) return;
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey) {
if (key === 's') { event.preventDefault(); save(); }
// Keep native text-field undo intact; the toolbar always undoes the complete document.
if (inField(event.target)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
return;
}
if (event.key === 'Escape') {
if (!$('#context-menu').hidden) { closeContextMenu(); return; }
if (document.querySelector('dialog[open]') || inlineEdit) return;
if (pendingInsert) { setPendingInsert(null); if (selected) setView('element'); return; }
if (selected) { choose(null); $('.canvas-scroll').focus({ preventScroll: true }); }
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 {} };
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('keydown', onKey);
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
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();
+14 -5
View File
@@ -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)}
@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(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. */
@media (prefers-reduced-motion: no-preference) {
/* Hover effects remain CSS; entrance effects are observed by motion.js. They play for everyone
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:hover { transform: translateY(3px) }
.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-grow-border { transition: box-shadow .2s ease }
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
}
/* 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
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
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 }
/* 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) }
@@ -44,4 +42,15 @@
/* 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: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
View File
@@ -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) {
const view = doc.defaultView;
const html = doc.documentElement;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
const reduced = () => html.classList.contains('motion-respect') && preference.matches;
const animations = new Set();
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 = {
fade: { opacity: .2 }, rise: { transform: 'translateY(28px)' }, sink: { transform: 'translateY(-28px)' },
left: { transform: 'translateX(-36px)' }, right: { transform: 'translateX(36px)' },
zoom: { transform: 'scale(.92)' }, flip: { transform: 'perspective(800px) rotateX(-16deg)' }, blur: { filter: 'blur(6px)' },
fade: { opacity: 0 },
rise: { opacity: 0, transform: 'translateY(40px)' },
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 => {
if (seen.has(node) || preference.matches) return;
if (seen.has(node) || reduced()) return;
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;
// 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 finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
const first = { ...start[name] };
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' });
animations.add(animation); animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
if (first.filter && finish.filter !== 'none') first.filter += ' ' + finish.filter;
// `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 => {
for (const entry of entries) if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
}, { threshold: 0, rootMargin: '0px 0px -24px 0px' });
for (const entry of entries) {
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 = () => {
if (preference.matches) {
if (reduced()) {
observer.disconnect();
animations.forEach(animation => animation.cancel());
[...hidden].forEach(unhide);
} else {
// Reduction may have been active at startup or enabled during this visit.
// Resume observing unseen content when movement is allowed again.
@@ -37,8 +82,15 @@ export function initMotion(doc = document) {
}
};
preference.addEventListener('change', syncPreference);
view.addEventListener('scroll', onScroll, { passive: true });
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();
+4 -4
View File
@@ -364,10 +364,10 @@ body.consent-open{padding-bottom:260px}
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
body.consent-open .scroll-top{display:none}
@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)}
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
}
.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}}
/* 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
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
+7 -5
View File
@@ -8,8 +8,10 @@ export function initSliders(doc = document) {
slider.dataset.enhanced = 'true';
const events = new view.AbortController(), signal = events.signal;
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);
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';
for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name);
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
@@ -28,7 +30,7 @@ export function initSliders(doc = document) {
};
const go = index => {
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 visible = available(), position = visible.indexOf(active());
@@ -58,12 +60,12 @@ export function initSliders(doc = document) {
if (play) {
play.textContent = playing ? 'Pause' : 'Wiedergabe';
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 = () => {
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); };
slider.after(controls); slider.classList.add('slider-controlled'); update();
@@ -78,7 +80,7 @@ export function initSliders(doc = document) {
}, { signal });
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.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();
cleanups.push(() => { clearTimeout(timer); events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
});
+61 -48
View File
@@ -55,6 +55,10 @@ try {
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
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');
assert.equal(new URL(page.url()).pathname, '/admin/login');
await axe('login');
@@ -104,31 +108,22 @@ try {
const frame = page.frameLocator('#preview');
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
// Flexible panels, 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');
// One sidebar, contextual fields and a root insertion from a deeply selected element.
await page.reload();
await frame.locator('h1').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]) {
await page.setViewportSize({ width, height: 1100 });
await page.waitForTimeout(100);
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}`);
}
}
await page.setViewportSize({ width: 1600, height: 1100 });
await frame.locator('h1').click();
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.locator('#undo').click();
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.locator('#undo').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();
assert.equal(await page.locator('#style-fontSize').count(), 0);
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('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
await page.locator('.sidebar-body').evaluate(el => el.scrollTop = 0);
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
await leftHandle.dblclick(); await rightHandle.dblclick();
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.
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('h1').click();
await page.locator('#add-root-section').click();
await addFromLibrary('[data-add="section"]');
await previewReady();
const emptySection = frame.locator('main > .editor-empty-container');
await emptySection.waitFor();
@@ -197,10 +190,11 @@ try {
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/);
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 previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
await tab('Elemente');
await page.locator('#library-search').fill('');
const dragSource = page.locator('#library [data-add="button"]');
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
@@ -249,6 +243,7 @@ try {
const destination = frame.locator(`#${secondColumnId}`);
await destination.scrollIntoViewIfNeeded();
const box = await destination.boundingBox();
await tab('Struktur');
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 },
});
@@ -268,7 +263,7 @@ try {
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');
// 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('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
@@ -306,7 +301,7 @@ try {
const headingId = await inPreview('h1');
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
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('#insert-hint').isVisible(), false);
await page.waitForFunction(id => {
@@ -318,6 +313,7 @@ try {
// Ziehen aus dem Werkzeugkasten auf die Seite.
await frame.locator('h1').waitFor();
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.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
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
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// einem anderen Dokument.
await tab('Struktur');
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 toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
// Und in der Zeile wird die Spalte moeglich.
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
await page.locator('#library button[data-row="3"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
await addFromLibrary('button[data-row="3"]');
assert.equal(await page.locator('#properties-title').textContent(), 'Zeile');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
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
@@ -367,6 +364,7 @@ try {
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();
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
await tab('Elemente');
const mitBild = await page.locator('#library button .library-preview svg').count();
const knoepfe = await page.locator('#library button').count();
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 });
console.log('Preview edits and history passed.');
await tab('Struktur');
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
assert.equal(await page.locator('#structure .tree-item').count(), 3);
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('');
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
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();
const frameValue = async (selector, property, expected) => {
@@ -495,7 +496,7 @@ try {
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.
await page.locator('#structure [data-block-id="hero"]').click();
await treeSelect('hero');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await setLength('style-padding-top', 96);
await frameValue('#hero', 'paddingTop', '96px');
@@ -539,7 +540,7 @@ try {
await frameValue('#hero', 'paddingTop', '640px');
// 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');
const scrub = page.locator('#style-fontSize');
await scrub.scrollIntoViewIfNeeded();
@@ -558,7 +559,7 @@ try {
// 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
// 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.locator('#style-padding-unit').selectOption('px');
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 });
await tab('Elemente');
await page.locator('[data-preset="columns"]').click();
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
const presetRow = `#${presetSection} .block-row`;
@@ -607,12 +609,12 @@ try {
await frame.locator(presetRow).waitFor({ state: 'detached' });
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
await columnCount(2);
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
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 });
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');
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
@@ -639,12 +641,12 @@ try {
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
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 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: '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 openGroup('Layout');
await page.locator('#style-gridColumns').fill('2');
@@ -661,7 +663,7 @@ try {
await openGroup('Bewegung und Effekte');
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
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 frame.locator(`#${galleryId}.fx-entrance-${effect}`).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.scrollIntoView();
}, 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);
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.evaluate(() => { window.observedEntrance = []; });
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
await page.waitForTimeout(800);
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback');
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForFunction(() => window.observedEntrance.length > 0);
assert.equal(await page.locator('#motion-status').isVisible(), false, 'no motion note while the site ignores the preference');
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' });
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 frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
@@ -698,7 +712,7 @@ try {
await frameValue(`#${galleryId}`, 'animationName', 'none');
await frameValue(`#${galleryId}`, 'transform', 'none');
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(`[data-media-id="${mediaItems[0].id}"]`).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.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
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();
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.');
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.getByRole('button', { name: 'Duplizieren', exact: true }).click();
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: 'Löschen', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.locator(`#structure [data-toggle="${presetSection}"]`).click();
await page.locator(`#structure [data-toggle="${galleryId}"]`).click();
await treeToggle(presetSection);
await treeToggle(galleryId);
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.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.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
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
// structure rather than for a particular save status.
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"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await axe('desktop editor');
@@ -894,7 +907,7 @@ try {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await axe('mobile editor');
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 axe('mobile media library');
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
+76 -29
View File
@@ -55,10 +55,12 @@ try {
assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform');
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 target.evaluate(node => node.style.opacity), '0', 'Below-fold block waits out of sight, as in Flatsome');
await target.scrollIntoViewIfNeeded();
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'));
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.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');
@@ -79,37 +81,67 @@ try {
const animation = node.getAnimations()[0]; animation.pause(); animation.currentTime = 150;
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]);
assert.ok(direction === 'left' ? offset < 0 : offset > 0, 'Computed movement follows selected direction');
await page.screenshot({ path: `${report}/${direction}-${width}.png` });
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.reload(); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(800);
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.reload(); await target.scrollIntoViewIfNeeded();
await page.waitForFunction(() => motionCalls.some(call => call.id === 'kontakt'), null, { timeout: 3000 });
await page.waitForTimeout(800);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForTimeout(900);
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.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();
// 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 page = await noJs.newPage(); await page.goto(origin);
@@ -124,13 +156,28 @@ try {
await editor.getByRole('button', { name: 'Anmelden', exact: true }).click();
await editor.waitForURL(origin + '/admin');
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.screenshot({ path: `${report}/editor-motion-status.png` });
await editor.emulateMedia({ reducedMotion: 'no-preference' });
await editor.locator('#motion-status').waitFor({ state: 'hidden' });
await editor.emulateMedia({ reducedMotion: 'reduce' });
await editor.locator('#motion-status').waitFor({ state: 'visible' });
await editor.waitForTimeout(300);
assert.equal(await editor.locator('#motion-status').isVisible(), false, 'No motion note while the site ignores the preference');
await editor.close();
// The note appears once the site respects the preference - simulated in the preview frame.
// 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();
assert.deepEqual(errors, []);
assert.equal(await hash(), before, 'Original database unchanged');
+5
View File
@@ -29,6 +29,10 @@ final class SiteSettings
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
'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
// Kopf mitbringt - sonst steht die Zeile über einem Bild, das den Titel schon trägt.
'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.');
}
$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.
// 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
+69 -26
View File
@@ -8,44 +8,87 @@
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block body %}
{# 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' }}">
<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="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="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>
<div class="viewport-controls" role="group" aria-label="Vorschaugröße">
<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>
<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>
<main id="admin-main" class="editor-layout" tabindex="-1">
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
<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>
<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>
<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>
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
<input id="library-search" type="search" placeholder="z. B. Galerie, Zeile, Formular …" autocomplete="off">
<p id="library-found" class="field-help" role="status"></p>
<div id="library" class="library"></div>
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
<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>
</div></section>
<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>
<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>
</section>
<aside class="sidebar" aria-label="Bearbeitung" data-view="elements">
{# The top of the sidebar answers "where am I". Without a selection it is a small
set of tabs; with one it is the element's name, the way back up and the plus. #}
<div class="sidebar-head">
<div class="sidebar-nav" role="tablist" aria-label="Seitenleiste">
<button type="button" role="tab" data-view="elements" aria-selected="true">Elemente</button>
<button type="button" role="tab" data-view="structure" aria-selected="false">Struktur</button>
<button type="button" id="select-page" role="tab" data-view="page" aria-selected="false">Seite</button>
</div>
<div class="sidebar-element" hidden>
<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 class="sidebar-element-name"><h2 id="properties-title">Element</h2><nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav></div>
<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>
<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>
</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>
<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">
<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>
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
</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>
<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>
<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>
+6
View File
@@ -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>
<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 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="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>
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="de">
<html lang="de"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
+2 -1
View File
@@ -1,5 +1,6 @@
<!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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
+9 -8
View File
@@ -1687,15 +1687,16 @@ final class CmsTest extends TestCase
$html = $this->request('/')->getContent();
self::assertStringContainsString('<span class="post-view" aria-hidden="true">Ansehen</span>', $html);
// Die Bewegung steht vollständig hinter der Bewegungsschranke, der Hinweis selbst nicht:
// wer weniger Bewegung eingestellt hat, sieht ihn trotzdem.
// Die Bewegung spielt für alle; beachtet die Website die Browservorgabe, nimmt ein
// Überschreibblock hinter der Bewegungsschranke nur die Fahrt weg, der Hinweis bleibt.
$effects = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
// Der erste Abschnitt steht vor der ersten Schranke; alles dahinter ist geschützt.
$guarded = explode('@media (prefers-reduced-motion: no-preference) {', $effects);
$geschuetzt = implode('', array_slice($guarded, 1));
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $geschuetzt);
self::assertStringContainsString('transform: scale(1.06)', $geschuetzt);
self::assertStringNotContainsString('transform: translateY(-8px)', $guarded[0], 'Keine Bewegung außerhalb der Schranke.');
self::assertStringContainsString('.post-card:hover, .post-card:focus-within {', $effects);
self::assertStringContainsString('transform: scale(1.06)', $effects);
$schranke = explode('@media (prefers-reduced-motion: reduce) {', $effects);
self::assertCount(2, $schranke, 'Genau ein Überschreibblock für „Bewegung reduzieren“.');
self::assertStringContainsString('html.motion-respect', $schranke[1]);
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.
$site = (string) file_get_contents(__DIR__.'/../public/assets/site.css');