diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0e802d4..5cd8840 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -440,6 +440,22 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
+## ADR 044 – Genauer hinsehen: Punkte statt Rollleiste, Karten statt Zeilen (13.09.2026)
+
+Umut hat den Nachbau Detail für Detail mit dem Original verglichen, und er hatte in jedem Punkt recht. Was dabei herauskam, sind nicht Geschmacksfragen, sondern vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.
+
+**Ein Slider ist kein Schiebefeld.** Der Slider war ein Scrollbereich mit sichtbarer Rollleiste – im Hero sah das aus wie ein Versehen. Das Original hat Punkte. Punkte gibt es jetzt, und zwar **ohne Skript**: `scroll-marker-group` und `::scroll-marker` lassen den Browser je Folie einen Punkt zeichnen, der aktive ist über `:target-current` erkennbar, und ein Klick scrollt. Wo ein Browser das noch nicht kennt (heute Firefox und Safari), bleibt ein wisch- und tastaturbedienbares Schiebefeld – also kein Ausfall, nur weniger Komfort. Die Rollleiste ist dort ausgeblendet, wo Punkte die Orientierung übernehmen.
+
+**Pfeile gibt es bewusst nicht.** `::scroll-button()` existiert, aber Chrome legt die Knöpfe weder absolut noch klebend in den Slider, sondern an den Rand der Seite. Lieber keine Pfeile als ein Knopf, der am Seitenrand schwebt.
+
+**Was der Slider nicht kann, steht hier:** er rotiert nicht von selbst. Eine öffentliche Seite lädt kein JavaScript, und eine CSS-Animation, die sich nicht anhalten lässt, wäre ein Barrierefreiheitsproblem statt einer Lösung.
+
+**Eine Beitragskarte war keine Karte.** Sie war eine Spalte aus Bild, Überschrift und Text ohne Rahmen. Im Original ist sie eine Karte: weiß, 18 px Ecken, Bild bündig oben im Verhältnis 16:10, Text in einem eigenen Feld darunter. Genau so sieht sie jetzt aus – und zwar für jede Website, weil es die Darstellung des Bausteins ist und keine Einstellung dieser einen Seite.
+
+**Die Schriftgrößen der Bausteine verloren gegen die Theme-Skala.** `.kant h3` aus dem Theme und `.block-iconcard h3` aus `site.css` hatten dieselbe Spezifität, und der Themeblock steht später im Dokument – also gewann er. Jede Kartenüberschrift war so groß wie eine Abschnittsüberschrift, auf jeder Website mit eigener Schriftskala. Die Bausteinregeln tragen jetzt `.kant` davor und gewinnen wieder.
+
+**Eine Überschrift darf ein Wort hervorheben.** Im Original ist die zweite Zeile der Hauptüberschrift rot. Dafür dürfen Überschriften ab v17 Auszeichnungen tragen – durch denselben Filter wie jeder Text. **Dabei wäre beinahe eine Lücke entstanden:** die Fassung des Dokuments war in der Schliessung, die den Filter läuft, gar nicht vorhanden; der Filter lief nie, und der Inhalt wird unescaped ausgegeben. Der Test, der ein `` 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('