From e21dff91fe166952c286d57d44938cd12f09d051 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sun, 13 Sep 2026 00:23:02 +0200 Subject: [PATCH] Slider mit Punkten, Karten als Karten, hervorgehobenes Wort Umut hat den Nachbau Detail fuer Detail mit kant-it.de verglichen. Vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat. Der Slider zeigte eine Rollleiste statt Punkte. Punkte zeichnet jetzt der Browser selbst - scroll-marker-group und ::scroll-marker, ohne Skript, mit erkennbarem aktivem Punkt. Wo ein Browser das noch nicht kennt, bleibt das wischbare Schiebefeld. Pfeile gibt es bewusst nicht: Chrome legt ::scroll-button an den Seitenrand statt in den Slider. Und er rotiert nicht von selbst, weil eine oeffentliche Seite kein JavaScript laedt. Eine Beitragskarte war keine Karte, sondern eine lose Spalte. Jetzt weiss, 18px Ecken, Bild buendig oben im Verhaeltnis 16:10, Text darunter. Die Theme-Schriftskala ueberschrieb die Groessen der Bausteine: jede Kartenueberschrift war so gross wie eine Abschnittsueberschrift. Eine Ueberschrift darf ab v17 ein Wort hervorheben. Dabei waere beinahe eine Luecke entstanden: die Dokumentfassung war in der Schliessung des Filters nicht gebunden, der Filter lief nie, die Ausgabe war schon unescaped. Ein Test mit ` in eine Überschrift schreibt, hat es im ersten Lauf gezeigt. Er prüft jetzt beides: das hervorgehobene Wort kommt durch, das Skript nicht. +- Gegen das Original nachgemessen statt geschätzt: Kartenbreite 316 px (Original 324), Kartenhöhe 342 (330), Kartentitel 19 px/800 in beiden, Überschrift zweiter Ebene 44 px in beiden, Hauptüberschrift 66 px (68). +- **Nicht geprüft:** Punkte am Slider in Firefox und Safari – dort greift der Rückfall auf ein wischbares Schiebefeld, gesehen habe ich ihn in diesen Browsern nicht. + ## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026 - **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung. diff --git a/docs/STATUS.md b/docs/STATUS.md index d566b2c..51a1717 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -27,6 +27,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - 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. - **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte. - **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz. +- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (``, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern. - **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen. - **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt. - **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht. diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 6219376..40585fc 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -52,7 +52,7 @@ export function createBlock(type) { if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 }; if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' }; if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true }; - if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true }; + if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true }; if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 }; return block; } diff --git a/public/assets/editor.js b/public/assets/editor.js index ba2a71b..74e0867 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -260,6 +260,7 @@ function renderProperties() { field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true }); } if (block.type === 'slider') { + field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' }); for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) { field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined }); } diff --git a/public/assets/site.css b/public/assets/site.css index b9c3a2e..f442d89 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -21,10 +21,11 @@ p{hyphens:auto} .menu-level-2{flex-direction:column;align-items:flex-start;gap:2px} /* Posts */ -.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))} -.post-card{display:flex;flex-direction:column;gap:14px} -.post-image img{width:100%;height:210px;object-fit:cover;border-radius:10px;display:block} -.post-card h3{margin:0;font-size:19px;line-height:1.35} +.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))} +.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px} +.post-image{display:block}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)} +.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0} +.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px} .post-card h3 a{text-decoration:none} .post-card h3 a:hover{text-decoration:underline} .post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0} @@ -38,7 +39,7 @@ p{hyphens:auto} /* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */ .block-icon{flex-shrink:0;display:block} .block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px} -.block-iconcard h3{margin:0;font-size:19px;line-height:1.3} +.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3} .block-iconcard h3 a{text-decoration:none} .block-iconcard h3 a:hover{text-decoration:underline} .block-iconcard p{margin:0;line-height:1.65} @@ -54,7 +55,7 @@ p{hyphens:auto} .block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px} .block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block} .block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px} -.block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px} +.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px} .block-cta p{margin:0;line-height:1.6} .block-contact{display:grid;gap:14px;font-style:normal} .block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5} @@ -113,7 +114,7 @@ p{hyphens:auto} stays readable while the decision is open, because a wall would make "accept" the only way forward. Accepting and refusing are the same button in the same row. */ .consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6} -.consent-bar h2{font-size:16px;margin:0 0 8px} +.kant .consent-bar h2{font-size:16px;margin:0 0 8px} .consent-bar p{margin:0 0 14px} .consent-actions{display:flex;flex-wrap:wrap;gap:10px} .consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer} @@ -193,7 +194,8 @@ body.consent-open{padding-bottom:260px} .block-slider.slider-snap{scroll-snap-type:x mandatory} .slider-snap>.slider-slide{scroll-snap-align:start} .slider-slide{min-width:0} -.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px} +.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */ +.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px} .slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)} .slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)} .slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 3de06fa..d177b73 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -116,7 +116,8 @@ final class DocumentValidator } if (in_array($type, ['heading', 'text', 'button'], true)) { // From v12 a text block may carry the markup RichText allows, which needs more room. - $limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300; + // From v17 a heading may too - one highlighted word is a design need, not a luxury. + $limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300); $this->text($props['text'] ?? null, $limit); } if ('heading' === $type) { @@ -234,6 +235,12 @@ final class DocumentValidator $this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key)); } $this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.'); + if (isset($props['dots'])) { + // Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das + // geht erst ab v17, weil es vorher keine Eigenschaft dafür gab. + $this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.'); + $this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.'); + } } if ('embed' === $type) { // Everything about the embed is validated here, because the address ends up in an diff --git a/src/Content/RichText.php b/src/Content/RichText.php index b17bbf4..d45afcb 100644 --- a/src/Content/RichText.php +++ b/src/Content/RichText.php @@ -33,6 +33,12 @@ final class RichText return is_int($schemaVersion) && $schemaVersion >= 12; } + /** Ueberschriften duerfen erst ab v17 Auszeichnungen tragen. */ + public static function headings(mixed $schemaVersion): bool + { + return is_int($schemaVersion) && $schemaVersion >= 17; + } + /** * Returns the text with only allowed markup left. The result is safe to print unescaped. */ @@ -184,11 +190,21 @@ final class RichText if (!self::supports($document['schemaVersion'] ?? null)) { return $document; } - $walk = function (array $blocks) use (&$walk): array { + // Die Fassung wird in die Schliessung gebunden. Ohne das war sie darin schlicht nicht + // vorhanden, der Filter fuer Ueberschriften lief nie - und ihr Inhalt wird unescaped + // ausgegeben. Der Test, der ein '; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $html = $this->request('/')->getContent(); + self::assertStringContainsString('digitale Exzellenz', $html); + self::assertStringNotContainsString('alert(1)', $html); + self::assertStringNotContainsString('