Files
KantSites/src/Content/StyleSchema.php
T
kantarciandClaude Opus 5 67b85751cf
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Randlose Abschnitte, volle Bildschirmhoehe, Vollbild-Hero
Umuts Frage: "Haben wir bilder usw als volles bild startseite? 100% hoehe
usw?" Die Teile gab es, die Kombination nicht - und zwei Sachen fehlten
wirklich.

Abschnittsbreiten: neu "randlos" (ohne Begrenzung, ohne Rand, von Kante zu
Kante) und "breit" (1600 px) neben normal und schmal. Vorher war jeder Inhalt
auf 1180 px begrenzt; ein Bild von Kante zu Kante war nicht moeglich.

Hoeheneinheiten: neu dvh, svh, lvh. 100vh ist auf dem Handy hoeher als der
Bildschirm - es meint die Ansicht ohne Adressleiste, und der untere Rand
verschwindet darunter. 100dvh ist das, was jemand meint, der "volle Hoehe"
sagt.

Und eine Vorlage "Vollbild-Hero", die die ganze Kombination auf einen Klick
setzt: randlos, 100dvh (mobil 80dvh), Inhalt senkrecht zentriert,
Hintergrundbild formatfuellend, dunkle Ueberlagerung damit der Text lesbar
bleibt, Ueberschrift, Text, Button. Die Teile waren alle da, aber diese
Kombination zu finden war eine Recherche.

328 PHP-Tests / 1329 Assertions, 17 JS-Modelltests. Geprueft: vier Breiten
angenommen und eine erfundene abgewiesen, sieben Hoehenangaben mit dvh/svh/lvh
und zwei ungueltige, ein randloser Abschnitt mit formatfuellendem Bild, und
die Hero-Vorlage Eigenschaft fuer Eigenschaft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 20:52:48 +02:00

239 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Content;
/**
* The single source of truth for free block styling.
*
* Every property is declared once here with its type, its CSS counterpart and its limits. The
* validator, the CSS generator and the editor panel all read this definition, so the three can
* never drift apart: what the editor offers is exactly what the server accepts and renders.
*
* Values are stored as short strings ("18px", "#1a2b3c", "flex"), never as CSS text. Each type has
* an anchored pattern or a fixed list, so no declaration can escape its property.
*/
final class StyleSchema
{
public const DEVICES = ['desktop' => 'Desktop', 'tablet' => 'Tablet', 'mobile' => 'Mobil'];
/** 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. */
/**
* 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
* type – length | length-auto | color | number | choice | text
* css – the CSS property it becomes; sides/corners expand to four
* label – German label for the editor
* target – 'img' applies the declaration to the images inside the block instead of the block
* itself. object-fit on a figure does nothing; it belongs on the image.
* sideCss – how one side is spelled. "padding-top" follows the property name, but a border
* width is "border-top-width" and not "border-width-top" - written out, because
* guessing it wrong produces a declaration the browser silently drops.
*
* @return array<string, array{group: string, type: string, css: string|array, label: string, values?: array<string, string>, min?: float, max?: float, step?: float, sides?: bool, hover?: bool}>
*/
public static function properties(): array
{
return [
// ---- Typografie -------------------------------------------------------------
'fontFamily' => ['group' => 'text', 'type' => 'font', 'css' => 'font-family', 'label' => 'Schriftart'],
'fontSize' => ['group' => 'text', 'type' => 'length', 'css' => 'font-size', 'label' => 'Schriftgröße'],
'fontWeight' => ['group' => 'text', 'type' => 'choice', 'css' => 'font-weight', 'label' => 'Schriftstärke', 'values' => [
'100' => '100 · Thin', '200' => '200 · Extra Light', '300' => '300 · Light', '400' => '400 · Regular',
'500' => '500 · Medium', '600' => '600 · Semibold', '700' => '700 · Bold', '800' => '800 · Extra Bold', '900' => '900 · Black',
]],
'fontStyle' => ['group' => 'text', 'type' => 'choice', 'css' => 'font-style', 'label' => 'Schriftlage', 'values' => ['normal' => 'Normal', 'italic' => 'Kursiv']],
'lineHeight' => ['group' => 'text', 'type' => 'number', 'css' => 'line-height', 'label' => 'Zeilenhöhe', 'min' => 0.7, 'max' => 4, 'step' => 0.05],
'letterSpacing' => ['group' => 'text', 'type' => 'length', 'css' => 'letter-spacing', 'label' => 'Buchstabenabstand'],
'wordSpacing' => ['group' => 'text', 'type' => 'length', 'css' => 'word-spacing', 'label' => 'Wortabstand'],
'textAlign' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-align', 'label' => 'Ausrichtung', 'values' => [
'left' => 'Links', 'center' => 'Mittig', 'right' => 'Rechts', 'justify' => 'Blocksatz',
]],
'textTransform' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-transform', 'label' => 'Schreibweise', 'values' => [
'none' => 'Wie eingegeben', 'uppercase' => 'GROSSBUCHSTABEN', 'lowercase' => 'kleinbuchstaben', 'capitalize' => 'Erster Buchstabe Groß',
]],
'textDecoration' => ['group' => 'text', 'type' => 'choice', 'css' => 'text-decoration-line', 'label' => 'Textlinie', 'values' => [
'none' => 'Keine', 'underline' => 'Unterstrichen', 'line-through' => 'Durchgestrichen',
]],
'color' => ['group' => 'text', 'type' => 'color', 'css' => 'color', 'label' => 'Textfarbe', 'hover' => true],
// ---- Hintergrund ------------------------------------------------------------
'backgroundColor' => ['group' => 'background', 'type' => 'color', 'css' => 'background-color', 'label' => 'Hintergrundfarbe', 'hover' => true],
'gradientFrom' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Verlauf von'],
'gradientTo' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Verlauf bis'],
'gradientAngle' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Verlaufswinkel', 'min' => 0, 'max' => 360, 'step' => 1],
'backgroundImage' => ['group' => 'background', 'type' => 'media', 'css' => null, 'label' => 'Hintergrundbild'],
'backgroundSize' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-size', 'label' => 'Bildgröße', 'values' => [
'cover' => 'Füllend', 'contain' => 'Vollständig sichtbar', 'auto' => 'Originalgröße',
]],
'backgroundPosition' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-position', 'label' => 'Bildausschnitt', 'values' => [
'center' => 'Mittig', 'top' => 'Oben', 'bottom' => 'Unten', 'left' => 'Links', 'right' => 'Rechts',
'top left' => 'Oben links', 'top right' => 'Oben rechts', 'bottom left' => 'Unten links', 'bottom right' => 'Unten rechts',
]],
'backgroundRepeat' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-repeat', 'label' => 'Wiederholung', 'values' => [
'no-repeat' => 'Nicht wiederholen', 'repeat' => 'Kacheln', 'repeat-x' => 'Waagerecht kacheln', 'repeat-y' => 'Senkrecht kacheln',
]],
'backgroundAttachment' => ['group' => 'background', 'type' => 'choice', 'css' => 'background-attachment', 'label' => 'Scrollverhalten', 'values' => [
'scroll' => 'Mitscrollen', 'fixed' => 'Feststehend (Parallax)',
]],
'overlayColor' => ['group' => 'background', 'type' => 'color', 'css' => null, 'label' => 'Überlagerung'],
'overlayOpacity' => ['group' => 'background', 'type' => 'number', 'css' => null, 'label' => 'Deckkraft der Überlagerung', 'min' => 0, 'max' => 1, 'step' => 0.05],
// ---- Abstand ----------------------------------------------------------------
'padding' => ['group' => 'spacing', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand', 'sides' => true, 'sideCss' => 'padding-%s'],
'margin' => ['group' => 'spacing', 'type' => 'length-auto', 'css' => 'margin', 'label' => 'Außenabstand', 'sides' => true, 'sideCss' => 'margin-%s'],
// ---- Größe ------------------------------------------------------------------
'width' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'width', 'label' => 'Breite'],
'maxWidth' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'max-width', 'label' => 'Maximale Breite'],
'minWidth' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'min-width', 'label' => 'Mindestbreite'],
'height' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'height', 'label' => 'Höhe'],
'minHeight' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'min-height', 'label' => 'Mindesthöhe'],
'maxHeight' => ['group' => 'size', 'type' => 'length-auto', 'css' => 'max-height', 'label' => 'Maximale Höhe'],
'overflow' => ['group' => 'size', 'type' => 'choice', 'css' => 'overflow', 'label' => 'Überlauf', 'values' => [
'visible' => 'Sichtbar', 'hidden' => 'Abschneiden', 'auto' => 'Scrollen bei Bedarf',
]],
// ---- Rahmen -----------------------------------------------------------------
'borderWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'border-width', 'label' => 'Rahmenstärke', 'sides' => true, 'sideCss' => 'border-%s-width'],
'borderStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Rahmenart', 'values' => [
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
]],
'borderColor' => ['group' => 'border', 'type' => 'color', 'css' => 'border-color', 'label' => 'Rahmenfarbe', 'hover' => true],
'borderRadius' => ['group' => 'border', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Ecken', 'corners' => true],
'outlineWidth' => ['group' => 'border', 'type' => 'length', 'css' => 'outline-width', 'label' => 'Zusatzrahmen Stärke'],
'outlineColor' => ['group' => 'border', 'type' => 'color', 'css' => 'outline-color', 'label' => 'Zusatzrahmen Farbe', 'hover' => true],
'outlineOffset' => ['group' => 'border', 'type' => 'length', 'css' => 'outline-offset', 'label' => 'Zusatzrahmen Abstand'],
'outlineStyle' => ['group' => 'border', 'type' => 'choice', 'css' => 'outline-style', 'label' => 'Zusatzrahmen Art', 'values' => [
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
]],
// ---- Bild: wirkt auf die Bilder im Block, nicht auf den Block ----------------
'objectFit' => ['group' => 'image', 'type' => 'choice', 'css' => 'object-fit', 'label' => 'Bildausschnitt', 'target' => 'img', 'values' => [
'cover' => 'Füllen und beschneiden', 'contain' => 'Vollständig zeigen', 'fill' => 'Verzerren', 'none' => 'Originalgröße', 'scale-down' => 'Nur verkleinern',
]],
'objectPosition' => ['group' => 'image', 'type' => 'choice', 'css' => 'object-position', 'label' => 'Bildmitte', 'target' => 'img', 'values' => [
'center' => 'Mitte', 'top' => 'Oben', 'bottom' => 'Unten', 'left' => 'Links', 'right' => 'Rechts',
'top left' => 'Oben links', 'top right' => 'Oben rechts', 'bottom left' => 'Unten links', 'bottom right' => 'Unten rechts',
]],
'imageRatio' => ['group' => 'image', 'type' => 'choice', 'css' => 'aspect-ratio', 'label' => 'Seitenverhältnis', 'target' => 'img', 'values' => [
'auto' => 'Wie das Bild', '1/1' => 'Quadrat 1:1', '4/3' => 'Klassisch 4:3', '3/2' => 'Foto 3:2', '16/9' => 'Breitbild 16:9',
'21/9' => 'Panorama 21:9', '3/4' => 'Hochkant 3:4', '2/3' => 'Hochkant 2:3', '9/16' => 'Hochkant 9:16',
]],
'imageHeight' => ['group' => 'image', 'type' => 'length-auto', 'css' => 'height', 'label' => 'Bildhöhe', 'target' => 'img'],
'imageRadius' => ['group' => 'image', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Bildecken', 'target' => 'img', 'corners' => true],
'imageBorderWidth' => ['group' => 'image', 'type' => 'length', 'css' => 'border-width', 'label' => 'Bildrahmen Stärke', 'target' => 'img', 'sides' => true, 'sideCss' => 'border-%s-width'],
'imageBorderColor' => ['group' => 'image', 'type' => 'color', 'css' => 'border-color', 'label' => 'Bildrahmen Farbe', 'target' => 'img'],
'imageBorderStyle' => ['group' => 'image', 'type' => 'choice', 'css' => 'border-style', 'label' => 'Bildrahmen Art', 'target' => 'img', 'values' => [
'solid' => 'Durchgezogen', 'dashed' => 'Gestrichelt', 'dotted' => 'Gepunktet', 'double' => 'Doppelt', 'none' => 'Keiner',
]],
// ---- Layout -----------------------------------------------------------------
'display' => ['group' => 'layout', 'type' => 'choice', 'css' => 'display', 'label' => 'Anzeige', 'values' => [
'block' => 'Block', 'flex' => 'Flex', 'inline-flex' => 'Inline-Flex', 'grid' => 'Raster', 'inline-block' => 'Inline-Block', 'none' => 'Ausgeblendet',
]],
'flexDirection' => ['group' => 'layout', 'type' => 'choice', 'css' => 'flex-direction', 'label' => 'Flexrichtung', 'values' => [
'row' => 'Nebeneinander', 'row-reverse' => 'Nebeneinander, umgekehrt', 'column' => 'Untereinander', 'column-reverse' => 'Untereinander, umgekehrt',
]],
'flexWrap' => ['group' => 'layout', 'type' => 'choice', 'css' => 'flex-wrap', 'label' => 'Umbruch', 'values' => ['wrap' => 'Umbrechen', 'nowrap' => 'Nicht umbrechen']],
'justifyContent' => ['group' => 'layout', 'type' => 'choice', 'css' => 'justify-content', 'label' => 'Verteilung', 'values' => [
'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'space-between' => 'Außen verteilt', 'space-around' => 'Gleichmäßig', 'space-evenly' => 'Exakt gleich',
]],
'alignItems' => ['group' => 'layout', 'type' => 'choice', 'css' => 'align-items', 'label' => 'Ausrichtung quer', 'values' => [
'stretch' => 'Dehnen', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'baseline' => 'Grundlinie',
]],
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
'rowGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'row-gap', 'label' => 'Abstand zwischen Zeilen'],
'columnGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'column-gap', 'label' => 'Abstand zwischen Spalten'],
'alignSelf' => ['group' => 'layout', 'type' => 'choice', 'css' => 'align-self', 'label' => 'Eigene Ausrichtung quer', 'values' => [
'auto' => 'Wie die Gruppe', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'stretch' => 'Strecken', 'baseline' => 'Grundlinie',
]],
'flexGrow' => ['group' => 'layout', 'type' => 'number', 'css' => 'flex-grow', 'label' => 'Wachsen', 'min' => 0, 'max' => 12, 'step' => 1],
'flexShrink' => ['group' => 'layout', 'type' => 'number', 'css' => 'flex-shrink', 'label' => 'Schrumpfen', 'min' => 0, 'max' => 12, 'step' => 1],
'flexBasis' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'flex-basis', 'label' => 'Grundbreite'],
'position' => ['group' => 'layout', 'type' => 'choice', 'css' => 'position', 'label' => 'Positionierung', 'values' => [
'static' => 'Im Fluss', 'relative' => 'Relativ verschoben', 'absolute' => 'Freigestellt', 'sticky' => 'Klebt beim Scrollen',
]],
'top' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'top', 'label' => 'Abstand von oben'],
'right' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'right', 'label' => 'Abstand von rechts'],
'bottom' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'bottom', 'label' => 'Abstand von unten'],
'left' => ['group' => 'layout', 'type' => 'length-auto', 'css' => 'left', 'label' => 'Abstand von links'],
'zIndex' => ['group' => 'layout', 'type' => 'number', 'css' => 'z-index', 'label' => 'Ebene (vorn/hinten)', 'min' => -10, 'max' => 100, 'step' => 1],
// ---- Wirkung ----------------------------------------------------------------
'opacity' => ['group' => 'effect', 'type' => 'number', 'css' => 'opacity', 'label' => 'Deckkraft', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
'shadowX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten waagerecht'],
'shadowY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten senkrecht'],
'shadowBlur' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten weich'],
'shadowSpread' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Schatten größer'],
'shadowColor' => ['group' => 'effect', 'type' => 'color', 'css' => null, 'label' => 'Schattenfarbe', 'hover' => true],
'translateX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben waagerecht', 'hover' => true],
'translateY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Verschieben senkrecht', 'hover' => true],
'scale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Skalierung', 'min' => 0.1, 'max' => 3, 'step' => 0.01, 'hover' => true],
'rotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Drehung', 'min' => -360, 'max' => 360, 'step' => 1, 'hover' => true],
'shadowInset' => ['group' => 'effect', 'type' => 'choice', 'css' => null, 'label' => 'Schatten innen', 'values' => ['aussen' => 'Außen', 'innen' => 'Innen']],
'textShadowX' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten waagerecht'],
'textShadowY' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten senkrecht'],
'textShadowBlur' => ['group' => 'effect', 'type' => 'length', 'css' => null, 'label' => 'Textschatten weich'],
'textShadowColor' => ['group' => 'effect', 'type' => 'color', 'css' => null, 'label' => 'Textschattenfarbe'],
'blur' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Weichzeichnen (px)', 'min' => 0, 'max' => 40, 'step' => 0.5, 'hover' => true],
'brightness' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Helligkeit', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
'contrast' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Kontrast', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
'saturate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Farbsättigung', 'min' => 0, 'max' => 3, 'step' => 0.05, 'hover' => true],
'grayscale' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Graustufen', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
'sepia' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Sepia', 'min' => 0, 'max' => 1, 'step' => 0.05, 'hover' => true],
'hueRotate' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Farbdrehung (Grad)', 'min' => -180, 'max' => 180, 'step' => 1, 'hover' => true],
'invert' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Umkehren', 'min' => 0, 'max' => 1, 'step' => 0.05],
'mixBlendMode' => ['group' => 'effect', 'type' => 'choice', 'css' => 'mix-blend-mode', 'label' => 'Mischmodus', 'values' => [
'normal' => 'Normal', 'multiply' => 'Multiplizieren', 'screen' => 'Negativ multiplizieren', 'overlay' => 'Überlagern',
'darken' => 'Abdunkeln', 'lighten' => 'Aufhellen', 'color-dodge' => 'Farbig abwedeln', 'difference' => 'Differenz',
'exclusion' => 'Ausschluss', 'hue' => 'Farbton', 'saturation' => 'Sättigung', 'color' => 'Farbe', 'luminosity' => 'Leuchtkraft',
]],
'cursor' => ['group' => 'effect', 'type' => 'choice', 'css' => 'cursor', 'label' => 'Mauszeiger', 'values' => [
'auto' => 'Automatisch', 'pointer' => 'Hand', 'default' => 'Pfeil', 'not-allowed' => 'Gesperrt', 'grab' => 'Greifen', 'zoom-in' => 'Lupe',
]],
'transition' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Übergangsdauer (ms)', 'min' => 0, 'max' => 2000, 'step' => 25],
];
}
/** Properties that are written onto the images inside a block instead of the block itself. */
public static function imageTargets(): array
{
return array_keys(array_filter(self::properties(), static fn (array $p): bool => 'img' === ($p['target'] ?? null)));
}
/** Properties that may also be given a hover value. */
public static function hoverable(): array
{
return array_keys(array_filter(self::properties(), static fn (array $p): bool => $p['hover'] ?? false));
}
public static function sides(): array
{
return ['top' => 'Oben', 'right' => 'Rechts', 'bottom' => 'Unten', 'left' => 'Links'];
}
/** Corners round a box; they are not sides, and their CSS property is spelled differently. */
public static function corners(): array
{
return ['topLeft' => 'Oben links', 'topRight' => 'Oben rechts', 'bottomRight' => 'Unten rechts', 'bottomLeft' => 'Unten links'];
}
/** "topLeft" becomes the CSS fragment "top-left". */
public static function cornerCss(string $corner): string
{
return strtolower(preg_replace('/([A-Z])/', '-$1', $corner) ?? $corner);
}
}