diff --git a/docs/DESIGNER-VERGLEICH.md b/docs/DESIGNER-VERGLEICH.md index a2cbe8e..05b1ee1 100644 --- a/docs/DESIGNER-VERGLEICH.md +++ b/docs/DESIGNER-VERGLEICH.md @@ -26,6 +26,8 @@ Vor heute hatte der Designer 50 Eigenschaften, jetzt 93 – jede je Gerät (Desk | Wirkung | Schatten, Deckkraft, Animationen, Hover | **26**: Deckkraft, Schatten (auch **innen**), Textschatten, Verschieben, Skalieren, Drehen, Übergangsdauer, **Weichzeichnen, Helligkeit, Kontrast, Sättigung, Graustufen, Sepia, Farbdrehung, Umkehren, Mischmodus, Mauszeiger** | | Eigener Code | CSS je Element | **CSS je Element für alle, Skript je Element nur für Administratoren** (ADR 028) | | Sichtbarkeit je Gerät | „Hide on mobile/tablet/desktop“ | über `Anzeige: keine` je Gerät möglich, aber **nicht als eigener Schalter** – siehe „Was noch fehlt“ | +| Abschnittsbreite | Normal, breit, randlos | **Normal (1180 px), schmal (800 px), breit (1600 px), randlos** – randlos heißt ohne Begrenzung und ohne Rand, für Bild oder Farbfläche von Kante zu Kante | +| Volle Bildschirmhöhe | „Full screen“ je Abschnitt | über `Mindesthöhe: 100dvh` plus `Anzeige: flex` und `Ausrichtung quer: Mitte`; die Vorlage **„Vollbild-Hero“** setzt genau das auf einen Klick. Neu sind dafür die Einheiten **dvh, svh, lvh** – `100vh` ist auf dem Handy höher als der Bildschirm | ### Bewegung diff --git a/docs/STATUS.md b/docs/STATUS.md index 96c10e2..63329c4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -25,6 +25,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst. - Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar. - Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster. +- **Vollflächige Bilder und volle Bildschirmhöhe**: Abschnitte können **randlos** sein (ohne Begrenzung, ohne Rand, von Kante zu Kante) oder breit (1600 px). Für die Höhe gibt es jetzt die Einheiten **dvh, svh, lvh** – `100vh` ist auf dem Handy höher als der Bildschirm, `100dvh` ist das, was gemeint ist. Die Vorlage **„Vollbild-Hero“** setzt die ganze Kombination auf einen Klick: randlos, 100dvh (mobil 80dvh), Inhalt senkrecht zentriert, Hintergrundbild formatfüllend, dunkle Überlagerung, Überschrift, Text, Button. - **Designer ausgebaut** (ADR 036, Dokumentschema v15): **93 Gestaltungseigenschaften** statt 50, je Gerät und teils für Hover. Neu: Bild-Eigenschaften, die auf das Bild *im* Block wirken (Ausschnitt, Bildmitte, Seitenverhältnis, Höhe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und Spaltenabstände, Flex-Eigenschaften, Innen- und Textschatten, acht Bildfilter, Mischmodus, Zusatzrahmen. Neun Einblendungen mit Verzögerung, sieben Hover-Effekte, alle innerhalb von `prefers-reduced-motion`. Drei neue Bausteine: **Symbol**, **Fortschritt** (mit `role="meter"`) und **Slider** als Scrollbereich mit Einrasten – ohne JavaScript, ohne Autoplay. „Zeile, 2/3/4 Spalten“ legt eine Zeile samt Spalten auf einen Klick an. Der Vergleich mit Flatsome steht in `docs/DESIGNER-VERGLEICH.md`, mit dem was fehlt und warum. - **Zwei Fehler dabei gefunden und behoben:** Rahmenstärken je Seite erzeugten `border-width-top` – kein gültiges CSS, vom Browser stillschweigend verworfen, also **ohne jede Wirkung**. Und eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild. - **Update über Git** (ADR 035, Arbeitspaket N/UPDATE.md): `cms:update` und ein Bildschirm „System“ zeigen Fassung, Commit, verfügbaren Tag und eine Vorprüfung, die jede Bedingung einzeln nennt – unter anderem, ob Datenbank und Medienordner von Git ausgenommen sind. Der Ablauf: sichern, Wartungszustand (503 mit `Retry-After`, `/admin` bleibt offen), Checkout des Tags, Fassung prüfen, Composer, Migration, Cache, Rauchtest als eigener Prozess (`cms:health`). Scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt, die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte – und wenn der alte Stand antwortet, geht die Website wieder online. `--check`, `--dry-run`, `--to=v1.2.0` und `--maintenance=an|aus` gibt es auf der Konsole. @@ -75,6 +76,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen. - Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator. - Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab. +- Vollbild geprüft: vier Abschnittsbreiten werden angenommen und eine erfundene abgewiesen; sieben Höhenangaben, darunter dvh, svh und lvh, und zwei ungültige; ein randloser Abschnitt mit formatfüllendem Bild erzeugt `#id{width:100%}` und `#id img{object-fit:cover;height:100dvh}`. Die Hero-Vorlage ist im JavaScript-Modelltest nachgerechnet, Eigenschaft für Eigenschaft. - Designer geprüft: 16 Datensätze prüfen, dass jede Eigenschaftsform genau die versprochene Deklaration erzeugt – Rahmen je Seite in der richtigen Schreibweise, Ecken, Positionierung, Flex, getrennte Abstände, Innenschatten, Textschatten, Filter in fester Reihenfolge, Mischmodus, und die Bild-Eigenschaften in einer zweiten Regel `#id img{…}`. Jeder davon muss außerdem den Validator überleben, sonst bietet der Editor etwas an, das nicht gespeichert werden kann. Ein Test hält Schema und Editorpanel zusammen: eine Gruppe, die das Panel nicht kennt, würde ihre Eigenschaften in einem unbenannten Aufklappfeld verstecken. - Neue Bausteine geprüft: vier Darstellungen (dekoratives Symbol vor Screenreadern verborgen, sprechendes Symbol mit Text, Fortschritt mit `role="meter"` und Wert im Markup, Slider mit Klasse je Gerät) und acht abgewiesene Eingaben (Symbolgröße außerhalb der Liste, unbekanntes Symbol, sprechendes Symbol ohne Beschriftung, dekoratives mit Beschriftung, Fortschritt über 100, Fortschritt als Text, Slider mit sieben Folien, Slider ohne Gerätangabe). Keiner der drei braucht ein Skript: die Seite behält `script-src 'none'`. - Updater geprüft: sechs Tests gegen echte Git-Repositorys in einem Temp-Verzeichnis – Vorprüfung benennt geänderte Dateien und eine nicht ausgenommene Datenbank, ein Update wechselt den Tag in der geprüften Reihenfolge und hebt den Wartungszustand auf, ein gescheiterter Schritt setzt Code **und** Datenbank zurück und bringt die Website auf dem alten Stand wieder online, ein Probelauf verändert nichts, ein unbekannter Tag wird beim Checkout abgewiesen, und eine bereits installierte Fassung ist kein Fehler. Zusätzlich am echten Repository durchgespielt (siehe QUALITY). diff --git a/public/assets/design-panel.js b/public/assets/design-panel.js index c35e606..6826be6 100644 --- a/public/assets/design-panel.js +++ b/public/assets/design-panel.js @@ -19,11 +19,12 @@ export const GROUPS = { effect: 'Wirkung', }; -const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch']; +// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean. +const UNITS = ['px', 'rem', 'em', '%', 'vw', 'dvh', 'vh', 'svh', 'lvh', 'dvw', 'ch']; /** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */ function splitLength(value) { - const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? ''); + const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw)$/.exec(value ?? ''); if (match) return { number: match[1], unit: match[2] }; return { number: value === '0' ? '0' : '', unit: 'px' }; } diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index e54316d..2b38770 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -178,6 +178,40 @@ export function insertMedia(document, selected, items) { * A row on its own looks like nothing, which is why "add a row" was hard to find: what people * want is a row that already has columns in it. */ +/** + * A full screen hero: edge to edge, as tall as the screen, content centred, with room for a + * background image and a dark overlay so text stays readable on any photo. + * + * The parts all exist on their own - width "full", min-height in dvh, display flex, overlay - but + * finding that combination is a research project. This is the one click. + */ +export function insertHero(document, selected) { + const section = createBlock('section'); + section.layout = { tone: 'ink', width: 'full' }; + section.style = { + desktop: { + // dvh, not vh: on a phone 100vh is taller than the screen. + minHeight: '100dvh', display: 'flex', alignItems: 'center', + backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', + overlayColor: '#000000', overlayOpacity: 0.4, + padding: { top: '80px', right: '32px', bottom: '80px', left: '32px' }, + textAlign: 'center', + }, + mobile: { minHeight: '80dvh', padding: { top: '56px', right: '20px', bottom: '56px', left: '20px' } }, + }; + const heading = createBlock('heading'); + heading.props = { text: 'Deine Botschaft, ganzflächig.', level: 1 }; + const paragraph = createBlock('text'); + paragraph.props = { text: 'Ein Satz, der sagt, worum es geht. Wähle im Designbereich unter Hintergrund ein Bild und stelle die Deckkraft der Überlagerung so ein, dass der Text lesbar bleibt.' }; + const button = createBlock('button'); + section.children = [heading, paragraph, button]; + upgradeDocument(document, 10); + const top = blockPath(document.blocks, selected)[0]; + const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length; + document.blocks.splice(index, 0, section); + return section.id; +} + export function insertRow(document, columns, selected) { const count = Math.max(1, Math.min(6, Number(columns) || 2)); const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' }; @@ -193,6 +227,7 @@ export function insertRow(document, columns, selected) { } export function insertPreset(document, name, selected) { + if (name === 'hero') return insertHero(document, selected); if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.'); const section = createBlock('section'); const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; }; diff --git a/public/assets/editor.js b/public/assets/editor.js index a81ee69..08bfe44 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -299,7 +299,7 @@ function renderProperties() { } if (block.type === 'section') { field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } }); - field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal', narrow: 'Schmal' } }); + field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal (1180 px)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos heißt ohne Rand und ohne Begrenzung – für ein Bild oder eine Farbfläche von Kante zu Kante.' }); } } diff --git a/public/assets/site.css b/public/assets/site.css index ef5c8c2..cd42aa6 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -304,3 +304,14 @@ body.consent-open{padding-bottom:260px} .progress-fill-98{width:98%} .progress-fill-99{width:99%} .progress-fill-100{width:100%} + +/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is + what a picture or a colour field from edge to edge needs. A full width section drops its own + padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */ +.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))} +.width-full>.section-inner{width:100%;max-width:none;margin-inline:0} +@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}} + +/* A section that is a flex container centres its content box; the inner container must then be + allowed to grow, or a full height section would show a strip in the middle. */ +.block-section>.section-inner{flex:1 1 auto} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index ba38647..3ac664e 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -112,7 +112,7 @@ final class DocumentValidator continue; } $this->require(in_array($key, ['tone', 'width'], true), 'Unknown layout setting.'); - $this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow'], true), 'Invalid design token.'); + $this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow', 'wide', 'full'], true), 'Invalid design token.'); } if (in_array($type, ['heading', 'text', 'button'], true)) { // From v12 a text block may carry the markup RichText allows, which needs more room. diff --git a/src/Content/StyleSchema.php b/src/Content/StyleSchema.php index d234297..7ad35f0 100644 --- a/src/Content/StyleSchema.php +++ b/src/Content/StyleSchema.php @@ -18,8 +18,15 @@ final class StyleSchema /** A colour is a hex value or "@name", a reference into the site palette. */ public const COLOR = '/^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@[a-z][a-z0-9-]{0,31})$/D'; /** A length is a number with a unit, plus the two keywords that behave like one. */ - public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D'; - public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/D'; + /** + * A length is a number with a unit, plus the two keywords that behave like one. + * + * dvh/svh/lvh are in the list because 100vh is wrong on a phone: it means the viewport + * without the address bar, so a "full height" section is taller than the screen and the + * bottom edge sits under the browser chrome. 100dvh is what people mean. + */ + public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D'; + public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D'; /** * group – where the property appears in the editor diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 528e6e2..c3e3e34 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -31,7 +31,7 @@

Fertige Abschnitte

Wird nach dem aktuellen Seitenabschnitt eingefügt.

-
+

Struktur

Ziehen oder mit den Schaltflächen verschieben.

diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 3716c55..7b68d03 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -1776,6 +1776,83 @@ final class CmsTest extends TestCase ]); } + /** @return iterable */ + public static function sectionWidths(): iterable + { + yield 'normal' => ['normal', true]; + yield 'narrow' => ['narrow', true]; + yield 'wide' => ['wide', true]; + yield 'edge to edge' => ['full', true]; + yield 'invented' => ['riesig', false]; + } + + #[DataProvider('sectionWidths')] + public function testASectionCanRunEdgeToEdge(string $width, bool $accepted): void + { + $document = DemoContent::starter('Breite'); + $document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => $width]; + if (!$accepted) { + $this->expectException(InvalidDocument::class); + $this->pages->saveDraft('page-1', $document, 0); + + return; + } + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + self::assertStringContainsString('width-'.$width, $this->request('/')->getContent()); + } + + /** @return iterable */ + public static function heights(): iterable + { + // 100vh is wrong on a phone - it means the viewport without the address bar. The unit + // people actually want is dvh, so it has to be allowed. + yield 'dynamic viewport height' => ['100dvh', true]; + yield 'small viewport height' => ['100svh', true]; + yield 'large viewport height' => ['100lvh', true]; + yield 'classic viewport height' => ['100vh', true]; + yield 'pixels' => ['640px', true]; + yield 'invented unit' => ['100qh', false]; + yield 'no unit' => ['100', false]; + } + + #[DataProvider('heights')] + public function testAFullHeightSectionIsPossibleWithTheUnitThatWorksOnPhones(string $height, bool $accepted): void + { + $document = DemoContent::starter('Hoch'); + $document['schemaVersion'] = 10; + $document['blocks'][0]['style'] = ['desktop' => ['minHeight' => $height, 'display' => 'flex', 'alignItems' => 'center']]; + if (!$accepted) { + $this->expectException(InvalidDocument::class); + $this->pages->saveDraft('page-1', $document, 0); + + return; + } + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + $html = $this->request('/')->getContent(); + self::assertStringContainsString('min-height:'.$height, $html); + // Full height alone would put the content at the top; the flex properties centre it. + self::assertStringContainsString('display:flex;align-items:center', $html); + } + + public function testAFullBleedImageFillsTheSection(): void + { + $document = DemoContent::starter('Bildfläche'); + $document['schemaVersion'] = 15; + $document['blocks'][0]['layout'] = ['tone' => 'paper', 'width' => 'full']; + $document['blocks'][0]['style'] = ['desktop' => ['padding' => ['top' => '0', 'bottom' => '0']]]; + $document['blocks'][0]['children'][] = [ + 'id' => 'flaeche', 'type' => 'image', + 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Weite Landschaft', 'decorative' => false, 'width' => 2400, 'height' => 1200], + 'style' => ['desktop' => ['width' => '100%', 'objectFit' => 'cover', 'imageHeight' => '100dvh']], + ]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + $html = $this->request('/')->getContent(); + self::assertStringContainsString('width-full', $html); + self::assertStringContainsString('#flaeche{width:100%}', $html); + // The height and the crop belong on the image itself, not on its figure. + self::assertStringContainsString('#flaeche img{object-fit:cover;height:100dvh}', $html); + } + /** @return iterable, list}> */ public static function newBlocks(): iterable { diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs index dccca2e..04ebe0b 100644 --- a/tests/editor-model.test.mjs +++ b/tests/editor-model.test.mjs @@ -302,3 +302,26 @@ test('a row arrives with its columns, inside the selected container', () => { for (let i = 0; i < 4; i++) history.undo(); assert.deepEqual(history.document, fixture()); }); + +test('the hero preset assembles a full screen section that centres its content', () => { + const history = new EditorHistory(fixture()); + const id = history.change(doc => insertPreset(doc, 'hero', 's1')); + const hero = locate(history.document.blocks, id).block; + + assert.equal(hero.type, 'section'); + assert.equal(hero.layout.width, 'full', 'Edge to edge, or it is not full screen.'); + // dvh, not vh: on a phone 100vh is taller than the screen. + assert.equal(hero.style.desktop.minHeight, '100dvh'); + assert.equal(hero.style.mobile.minHeight, '80dvh'); + assert.equal(hero.style.desktop.display, 'flex'); + assert.equal(hero.style.desktop.alignItems, 'center'); + assert.equal(hero.style.desktop.backgroundSize, 'cover'); + assert.ok(hero.style.desktop.overlayOpacity > 0, 'An overlay, so text stays readable on a photo.'); + assert.deepEqual(hero.children.map(child => child.type), ['heading', 'text', 'button']); + assert.equal(hero.children[0].props.level, 1); + // Free styles need schema v10. + assert.ok(history.document.schemaVersion >= 10); + + history.undo(); + assert.deepEqual(history.document, fixture()); +});