Designer: 93 Eigenschaften, neun Einblendungen, drei neue Bausteine

Umut fragte nach Zeilen, nach Rahmen und Rundungen - und ob der Designer so
aufgestellt ist wie Flatsome. Der Vergleich steht in
docs/DESIGNER-VERGLEICH.md, mit der Einschraenkung, dass Flatsome hier nicht
installierbar war: das ist Wissen, keine Messung.

Zwei echte Fehler kamen dabei heraus, beide vorher unbemerkt.

Eine Rahmenstaerke je Seite wurde zu border-width-top. Das ist kein CSS, der
Browser verwirft die Zeile stillschweigend - Rahmenstaerken je Seite hatten
nie eine Wirkung, und es gab keine Meldung, die darauf hingewiesen haette. Die
Schreibweise steht jetzt je Eigenschaft im Schema, weil padding-top dem Namen
folgt und border-top-width nicht.

Und eine Rundung auf einem Bildblock landete auf dem figure, waehrend das Bild
darin eckig blieb. Eigenschaften koennen jetzt auf das Bild im Block zielen
(target 'img' im Schema, eine zweite Regel im Compiler) - deshalb tut
object-fit ueberhaupt etwas, denn auf einem figure tut es nichts.

Von 50 auf 93 Eigenschaften: Bild (Ausschnitt, Bildmitte, Seitenverhaeltnis,
Hoehe, Ecken, Rahmen), Positionierung mit z-index, getrennte Zeilen- und
Spaltenabstaende, Flex je Element, Innen- und Textschatten, acht Bildfilter
als eine filter-Deklaration in fester Reihenfolge, Mischmodus, Zusatzrahmen,
Mauszeiger. Neun Einblendungen mit Verzoegerung, sieben Hover-Effekte, alle
innerhalb von prefers-reduced-motion.

Drei neue Bausteine (Dokumentschema v15): Symbol als eigenes Element,
Fortschritt mit role=meter und dem Wert im Markup statt nur in der
Balkenbreite, und ein Slider als Scrollbereich mit Einrasten - ohne
JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur.

Zeilen waren nicht unauffindbar, sondern unbrauchbar leer: eine Zeile ohne
Spalten sieht aus wie nichts. Jetzt legt "Zeile, 2/3/4 Spalten" beides auf
einen Klick an, und im Werkzeugkasten steht, wie der Aufbau gemeint ist.

315 PHP-Tests / 1309 Assertions, 16 JS-Modelltests, axe auf dreizehn
Bildschirmen ohne Verstoesse. 16 Datensaetze rechnen jede Eigenschaftsform
gegen die erwartete Deklaration nach - die Pruefung, die die beiden Fehler
oben gefunden haette, wenn es sie vorher gegeben haette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 20:37:49 +02:00
1 parent 30d8d58bbf
commit 7251b82f09
18 files changed
+777 -27

No files matched your search

+125
View File
@@ -14,6 +14,8 @@ use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\RichText;
use App\Content\SiteSettings;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\InvalidMenu;
@@ -1774,6 +1776,129 @@ final class CmsTest extends TestCase
]);
}
/** @return iterable<string, array{string, array<string, mixed>, list<string>}> */
public static function newBlocks(): iterable
{
yield 'a decorative icon is hidden from screen readers' => [
'icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '', 'href' => ''],
['class="block-icon icon-size-48', 'aria-hidden="true"'],
];
yield 'a meaningful icon carries its label as text' => [
'icon', ['icon' => 'phone', 'size' => 32, 'decorative' => false, 'label' => 'Anrufen', 'href' => '/kontakt'],
['<a id="i1"', 'href="/kontakt"', '<span class="visually-hidden">Anrufen</span>'],
];
yield 'a progress bar states its value in the markup' => [
'progress', ['label' => 'Fortschritt', 'value' => 70, 'showValue' => true],
['role="meter"', 'aria-valuenow="70"', 'progress-fill-70', '70 %'],
];
yield 'a slider is a scroll container with a class per device' => [
'slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true],
['class="block-slider slide-desktop-3 slide-tablet-2 slide-mobile-1 slider-snap"', 'tabindex="0"', 'role="group"'],
];
}
#[DataProvider('newBlocks')]
public function testTheNewBlocksRenderWhatTheyPromise(string $type, array $props, array $expected): void
{
$document = DemoContent::starter('Mit '.$type);
$document['schemaVersion'] = 15;
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
foreach ($expected as $needle) {
self::assertStringContainsString($needle, $html, $type);
}
// No script is needed for any of them: the public page still forbids scripting.
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
/** @return iterable<string, array{string, array<string, mixed>}> */
public static function refusedNewBlocks(): iterable
{
// A size without a class would leave the icon without dimensions, so only the listed ones.
yield 'icon size off the list' => ['icon', ['icon' => 'star', 'size' => 37, 'decorative' => true, 'label' => '']];
yield 'unknown icon' => ['icon', ['icon' => 'erfunden', 'size' => 48, 'decorative' => true, 'label' => '']];
yield 'meaningful icon without a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => false, 'label' => '']];
yield 'decorative icon with a label' => ['icon', ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => 'Doch etwas']];
yield 'progress beyond a hundred' => ['progress', ['label' => 'X', 'value' => 140, 'showValue' => true]];
yield 'progress with a text value' => ['progress', ['label' => 'X', 'value' => '70', 'showValue' => true]];
yield 'slider with seven visible' => ['slider', ['desktopVisible' => 7, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true]];
yield 'slider missing a device' => ['slider', ['desktopVisible' => 3, 'tabletVisible' => 2, 'snap' => true]];
}
#[DataProvider('refusedNewBlocks')]
public function testRefusedNewBlocksNeverReachADocument(string $type, array $props): void
{
$document = DemoContent::starter('Mit '.$type);
$document['schemaVersion'] = 15;
$document['blocks'][] = ['id' => 'i1', 'type' => $type, 'props' => $props];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testTheNewBlocksNeedSchemaFifteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 14;
$document['blocks'][] = ['id' => 'i1', 'type' => 'icon', 'props' => ['icon' => 'star', 'size' => 48, 'decorative' => true, 'label' => '']];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/** @return iterable<string, array{array<string, mixed>, string}> */
public static function styleDeclarations(): iterable
{
// A border width per side is spelled border-top-width, not border-width-top. Written the
// wrong way the browser drops the declaration, so per-side borders had no effect at all.
yield 'border width per side' => [['borderWidth' => ['top' => '2px', 'left' => '4px']], '#b1{border-top-width:2px;border-left-width:4px}'];
yield 'padding keeps its own spelling' => [['padding' => ['top' => '12px']], '#b1{padding-top:12px}'];
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
yield 'text shadow' => [['textShadowY' => '2px', 'textShadowBlur' => '6px', 'textShadowColor' => '#00000080'], '#b1{text-shadow:0 2px 6px #00000080}'];
// The filter order is fixed by the compiler, not by the order someone set the values in.
yield 'filters in a fixed order' => [['grayscale' => '0.4', 'blur' => '2', 'brightness' => '1.1'], '#b1{filter:blur(2px) brightness(1.1) grayscale(0.4)}'];
yield 'hue rotation' => [['hueRotate' => '-30'], '#b1{filter:hue-rotate(-30deg)}'];
yield 'blend mode' => [['mixBlendMode' => 'multiply'], '#b1{mix-blend-mode:multiply}'];
// These belong on the image inside the block: object-fit on a figure does nothing.
yield 'image properties target the image' => [['objectFit' => 'cover', 'imageRatio' => '16/9'], '#b1 img{object-fit:cover;aspect-ratio:16/9}'];
yield 'image radius and border' => [['imageRadius' => ['topLeft' => '12px'], 'imageBorderWidth' => ['top' => '3px'], 'imageBorderColor' => '#2c6b4f'], '#b1 img{border-top-left-radius:12px;border-top-width:3px;border-color:#2c6b4f}'];
yield 'both at once, in two rules' => [['padding' => ['top' => '8px'], 'objectFit' => 'contain'], '#b1{padding-top:8px}#b1 img{object-fit:contain}'];
}
#[DataProvider('styleDeclarations')]
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected): void
{
$compiler = new StyleCompiler();
self::assertSame($expected, $compiler->compileDocument([
'blocks' => [['id' => 'b1', 'type' => 'image', 'style' => ['desktop' => $style]]],
]));
// And what compiles has to survive the validator, or the editor could offer it in vain.
$document = DemoContent::starter('Mit Stil');
$document['schemaVersion'] = 10;
$document['blocks'][0]['style'] = ['desktop' => $style];
$this->pages->saveDraft('page-1', $document, 0);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testTheStyleSchemaAndTheEditorPanelKnowTheSameGroups(): void
{
$groups = [];
foreach (StyleSchema::properties() as $property) {
$groups[$property['group']] = true;
}
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
foreach (array_keys($groups) as $group) {
// A group the panel does not know would hold its properties in an unnamed accordion.
self::assertMatchesRegularExpression('/
'.$group.': /', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
}
}
public function testALegalPathMustPointAtAPageThatExists(): void
{
foreach (['privacyPath', 'imprintPath'] as $key) {