Direktbearbeitung, Kontrastwarnung und Gestaltungsvorlagen

Inline-Textbearbeitung ohne Skript in der Vorschau: der Iframe führt
weiterhin kein eigenes JavaScript aus, der Editor greift in sein
Dokument und setzt das Element auf contenteditable. Während der
Bearbeitung wird die Vorschau nicht neu geladen, sonst wäre der
Cursor weg; Eingaben werden zu einem Undo-Schritt zusammengefasst.
Neben dem Doppelklick startet eine Schaltfläche dasselbe ohne Maus.

Kontrast wird nach WCAG 2.1 berechnet und als Warnung in drei Stufen
gemeldet, nicht als Verbot - der Plan verlangt Prüfung, nicht
Bevormundung.

Gestaltungsvorlagen liegen im Theme und gelten seitenübergreifend.
Sie werden mit demselben Compiler geprüft wie ein Blockstil, also
kann über diesen Weg kein Wert hereinkommen, für den ein Block
abgewiesen würde. Dazu Kopieren und Einfügen zwischen Blöcken und
Bildauswahl für Hintergrundbilder über die Mediathek.

Geprüft: 155 PHP-Tests / 579 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Lint/Format/Container. Erstmals wurde der
neue Editor im laufenden Betrieb bedient: Direktbearbeitung und
Kontrastwarnung mit echten Werten ausgelöst, Teständerungen
verworfen. Weiterhin keine axe- oder Lighthouse-Messung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 13:20:01 +02:00
1 parent abf0bd2c6b
commit 270cbda9ac
11 files changed
+388 -10

No files matched your search

+36 -1
View File
@@ -122,7 +122,7 @@ final class EditorSecurityTest extends WebTestCase
$this->post($client, $action, []);
self::assertResponseRedirects('http://localhost/admin/login');
}
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]]] as [$url, $body]) {
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]], ['/admin/api/style-presets', ['name' => 'X']]] as [$url, $body]) {
$client->request('POST', $url, $body);
self::assertResponseRedirects('http://localhost/admin/login');
}
@@ -403,6 +403,41 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
}
public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void
{
$client = $this->login('editor');
$client->request('GET', '/admin/api/design');
self::assertResponseIsSuccessful();
$design = json_decode($client->getResponse()->getContent(), true);
// The editor receives the vocabulary, the palette and the presets from one place.
self::assertArrayHasKey('fontSize', $design['properties']);
self::assertSame([], $design['presets']);
$token = $design['csrfToken'];
$post = function (array $body, string $csrf) use ($client): void {
$client->request('POST', '/admin/api/style-presets', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $csrf], content: json_encode($body));
};
$post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px']]], 'wrong');
self::assertResponseStatusCodeSame(403);
// A style the documents would refuse cannot arrive through a preset either.
$post(['name' => 'Boese', 'style' => ['desktop' => ['fontSize' => '32px;position:fixed']]], $token);
self::assertResponseStatusCodeSame(422);
$post(['name' => ' ', 'style' => ['desktop' => ['fontSize' => '32px']]], $token);
self::assertResponseStatusCodeSame(422);
self::assertStringNotContainsString('position:fixed', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
$post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px', 'padding' => ['top' => '40px']]]], $token);
self::assertResponseIsSuccessful();
$presets = json_decode($client->getResponse()->getContent(), true)['presets'];
self::assertSame('heller-abschnitt', $presets[0]['key']);
self::assertSame('32px', $presets[0]['style']['desktop']['fontSize']);
// Removing works by sending the same name without a style.
$post(['name' => 'Heller Abschnitt', 'style' => null], $token);
self::assertSame([], json_decode($client->getResponse()->getContent(), true)['presets']);
}
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
{
$client = $this->login('editor');