diff --git a/README.md b/README.md index c25511a..575b569 100644 --- a/README.md +++ b/README.md @@ -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 `` 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. diff --git a/VERSION b/VERSION index 5ad2491..169f19b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.10.2 +1.11.0 \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e45726..50b2b87 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 ``; `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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 1fa0dd5..08cb2b7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 ``, `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. diff --git a/public/assets/editor.css b/public/assets/editor.css index c50a0a5..4ce0829 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -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))} +} diff --git a/public/assets/editor.js b/public/assets/editor.js index c0d906f..5717e4f 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -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 . +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(); diff --git a/public/assets/enhancements.css b/public/assets/enhancements.css index 8bfb066..2944c46 100644 --- a/public/assets/enhancements.css +++ b/public/assets/enhancements.css @@ -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 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 } } diff --git a/public/assets/motion.js b/public/assets/motion.js index 3fc7e11..3514144 100644 --- a/public/assets/motion.js +++ b/public/assets/motion.js @@ -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 . 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(); diff --git a/public/assets/site.css b/public/assets/site.css index 072bc93..3e5d9c2 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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 diff --git a/public/assets/slider.js b/public/assets/slider.js index 159507d..4a580e7 100644 --- a/public/assets/slider.js +++ b/public/assets/slider.js @@ -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; }); }); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 0f47920..a26d291 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -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 }); diff --git a/scripts/motion-check.mjs b/scripts/motion-check.mjs index a33562d..b036f50 100644 --- a/scripts/motion-check.mjs +++ b/scripts/motion-check.mjs @@ -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": . 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 ; 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'); diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php index 40a98f6..5cb12b6 100644 --- a/src/Content/SiteSettings.php +++ b/src/Content/SiteSettings.php @@ -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 diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 92216b1..2902f4f 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -8,44 +8,87 @@ {% block scripts %}{% 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. #}
- ← {{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }} + {{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}

{{ subject.title }}

{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}
-
-
Wird geladen …{% if not area and is_granted('ROLE_PUBLISHER') %}{% endif %}
+
+ + + +
+
+ + + +
+
+ Wird geladen … + + {% if not area and is_granted('ROLE_PUBLISHER') %}{% endif %} + {% if not area %}{% endif %} +
-
+

Seite veröffentlichen?

Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.

Gestaltung als Vorlage speichern

Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.

diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index ad89c49..e23e628 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -165,6 +165,12 @@

Nur Kommentarzeilen und einfache Anweisungen wie Disallow: /pfad, Allow:, User-agent: und Crawl-delay:. 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.

Einwilligung

Nur nötig, wenn die Website etwas von Dritten lädt
+ + +

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.

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.

diff --git a/templates/archive.html.twig b/templates/archive.html.twig index 4543cd6..50966a9 100644 --- a/templates/archive.html.twig +++ b/templates/archive.html.twig @@ -1,5 +1,5 @@ - + diff --git a/templates/page.html.twig b/templates/page.html.twig index 32b88f7..bdc17bf 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -1,5 +1,6 @@ - +{# Ohne Klasse spielt die Website Bewegung immer ab; mit ihr gilt die Browservorgabe. #} + diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 55d79c1..c9bbd9f 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -1687,15 +1687,16 @@ final class CmsTest extends TestCase $html = $this->request('/')->getContent(); self::assertStringContainsString('', $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');