The icon set grows from 33 to 98 drawings in seven categories (arrows, contact, commerce, files and media, people and security, house and trade, general), including truck, cart, euro, camera, tools and solar. icons.js is the single source; npm run build:icons writes the Twig drawings and the validator list, and a test keeps them equal. Choosing an icon no longer fills the panel with tiles: the panel shows the current icon with "Change", which opens a modal with category tabs and named tiles, keyboard navigable, closing with Escape. Button, icon and icon card share it. A new style group "Symbol" targets the icon inside a button per device and on hover: colour, background, size, padding, radius, rotation, plus the gap to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only button whose label stays as the accessible name and keeps the line height. ADR 109. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
572 lines
26 KiB
PHP
572 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\InvalidDocument;
|
|
|
|
/**
|
|
* Validates free block styles and compiles them into CSS.
|
|
*
|
|
* Both directions read StyleSchema, so the editor, the stored document and the rendered page
|
|
* always agree. Nothing here ever concatenates user text into CSS: every value is matched against
|
|
* an anchored pattern or a fixed list first, and only then placed into a declaration that this
|
|
* class writes itself.
|
|
*/
|
|
final class StyleCompiler
|
|
{
|
|
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
|
|
|
|
/**
|
|
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
|
|
*
|
|
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
|
|
* holte ein Vorabverweis ein zweites Bild.
|
|
*/
|
|
public const PRELOAD_DEVICES = [
|
|
'desktop' => '(min-width:1025px)',
|
|
'tablet' => '(min-width:768px) and (max-width:1024px)',
|
|
'mobile' => '(max-width:767px)',
|
|
];
|
|
|
|
public function __construct(private ?MediaLibrary $media = null)
|
|
{
|
|
}
|
|
|
|
/** @throws InvalidDocument */
|
|
public function validate(mixed $style): void
|
|
{
|
|
$this->require(is_array($style), 'Style must be an object.');
|
|
$properties = StyleSchema::properties();
|
|
$hoverable = StyleSchema::hoverable();
|
|
foreach ($style as $device => $values) {
|
|
$this->require(isset(StyleSchema::DEVICES[$device]) && is_array($values), 'Unknown style device.');
|
|
foreach ($values as $key => $value) {
|
|
if ('hover' === $key) {
|
|
$this->require(is_array($value), 'Hover styles must be an object.');
|
|
foreach ($value as $hoverKey => $hoverValue) {
|
|
$this->require(in_array($hoverKey, $hoverable, true), sprintf('The property "%s" has no hover value.', (string) $hoverKey));
|
|
$this->value($properties[$hoverKey], $hoverValue, (string) $hoverKey);
|
|
}
|
|
continue;
|
|
}
|
|
if ('scrolled' === $key) {
|
|
$this->require(is_array($value), 'Scrolled styles must be an object.');
|
|
foreach ($value as $scrolledKey => $scrolledValue) {
|
|
$this->require(in_array($scrolledKey, StyleSchema::scrollable(), true), sprintf('The property "%s" has no scrolled value.', (string) $scrolledKey));
|
|
$this->value($properties[$scrolledKey], $scrolledValue, (string) $scrolledKey);
|
|
}
|
|
continue;
|
|
}
|
|
$this->require(is_string($key) && isset($properties[$key]), sprintf('Unknown style property "%s".', (string) $key));
|
|
$this->value($properties[$key], $value, $key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function value(array $property, mixed $value, string $key): void
|
|
{
|
|
if (($property['sides'] ?? false) || ($property['corners'] ?? false)) {
|
|
$parts = ($property['corners'] ?? false) ? StyleSchema::corners() : StyleSchema::sides();
|
|
$this->require(is_array($value), sprintf('"%s" is given per part.', $key));
|
|
foreach ($value as $part => $single) {
|
|
$this->require(isset($parts[$part]), sprintf('Unknown part for "%s".', $key));
|
|
$this->scalar($property, $single, $key);
|
|
}
|
|
|
|
return;
|
|
}
|
|
$this->scalar($property, $value, $key);
|
|
}
|
|
|
|
private function scalar(array $property, mixed $value, string $key): void
|
|
{
|
|
$this->require(is_string($value) || is_int($value) || is_float($value), sprintf('"%s" needs a simple value.', $key));
|
|
$text = (string) $value;
|
|
match ($property['type']) {
|
|
'color' => $this->require(1 === preg_match(StyleSchema::COLOR, $text), sprintf('"%s" is not a colour.', $key)),
|
|
'length' => $this->require(1 === preg_match(StyleSchema::LENGTH, $text), sprintf('"%s" is not a length.', $key)),
|
|
'length-auto' => $this->require(1 === preg_match(StyleSchema::LENGTH_AUTO, $text), sprintf('"%s" is not a length.', $key)),
|
|
'choice' => $this->require(isset($property['values'][$text]), sprintf('"%s" is not an allowed value for %s.', $text, $key)),
|
|
'number' => $this->require(
|
|
is_numeric($value) && (float) $value >= $property['min'] && (float) $value <= $property['max'],
|
|
sprintf('"%s" is outside its range.', $key),
|
|
),
|
|
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
|
// A share of nothing is a column that cannot be seen, so zero is refused with the rest.
|
|
'fractions' => $this->require(
|
|
1 === preg_match(StyleSchema::FRACTIONS, $text) && !in_array(0.0, array_map(floatval(...), explode(' ', $text)), true),
|
|
sprintf('"%s" is not a list of column shares.', $key),
|
|
),
|
|
'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
|
default => $this->require(false, 'Unknown style type.'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compiles every styled block of a document into one stylesheet.
|
|
*
|
|
* @return string CSS, already escaped by construction; empty when nothing is styled
|
|
*/
|
|
public function compileDocument(array $document): string
|
|
{
|
|
$css = '';
|
|
$walk = function (array $blocks, array $parent = []) use (&$walk, &$css): void {
|
|
foreach ($blocks as $block) {
|
|
if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) {
|
|
$css .= $this->compileBlock((string) $block['id'], $block['style']);
|
|
if ('image' === ($block['type'] ?? null)) {
|
|
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
|
}
|
|
if ('row' === ($block['type'] ?? null)) {
|
|
$css .= $this->columnTracks((string) $block['id'], $block['style'], is_array($block['layout'] ?? null) ? $block['layout'] : [], count($block['children'] ?? []));
|
|
}
|
|
}
|
|
$walk($block['children'] ?? [], $block);
|
|
}
|
|
};
|
|
$walk($document['blocks'] ?? []);
|
|
|
|
return $css;
|
|
}
|
|
|
|
/**
|
|
* In a normal section/column, align-self cannot position a figure. Align its box
|
|
* and the possibly smaller image inside it, including linked images. A horizontal
|
|
* flex group must keep align-self's vertical axis without consuming its main-axis
|
|
* space with automatic margins. Resolve both styles at each breakpoint so a parent
|
|
* layout change or "auto" on a phone cannot leave desktop alignment behind.
|
|
*/
|
|
private function imageAlignment(string $id, array $style, array $parent): string
|
|
{
|
|
$css = $previous = '';
|
|
$values = $group = [];
|
|
// These block types render children in an unstyled inner wrapper.
|
|
$wrapped = in_array($parent['type'] ?? '', ['section', 'accordionitem', 'slider'], true);
|
|
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
|
$values = array_replace_recursive($values, $style[$device] ?? []);
|
|
$group = array_replace_recursive($group, $parent['style'][$device] ?? []);
|
|
if (!isset($values['alignSelf'])) {
|
|
continue;
|
|
}
|
|
$alignment = match ($values['alignSelf']) {
|
|
'center' => ['auto', 'auto', 'center'],
|
|
'flex-start' => ['0', 'auto', 'start'],
|
|
'flex-end' => ['auto', '0', 'end'],
|
|
default => null,
|
|
};
|
|
$horizontalFlex = !$wrapped
|
|
&& in_array($group['display'] ?? '', ['flex', 'inline-flex'], true)
|
|
&& in_array($group['flexDirection'] ?? 'row', ['row', 'row-reverse'], true);
|
|
[$left, $right, $justify] = $horizontalFlex || null === $alignment
|
|
? [$values['margin']['left'] ?? '0', $values['margin']['right'] ?? '0', 'auto']
|
|
: $alignment;
|
|
$rules = sprintf('#%s{margin-left:%s;margin-right:%s;justify-self:%s}#%s img{margin-left:%s;margin-right:%s}',
|
|
$id, $left, $right, $justify, $id, $alignment[0] ?? '0', $alignment[1] ?? '0');
|
|
if ($rules !== $previous) {
|
|
$css .= isset(self::BREAKPOINTS[$device])
|
|
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
|
|
: $rules;
|
|
$previous = $rules;
|
|
}
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
|
|
private function compileBlock(string $id, array $style): string
|
|
{
|
|
$css = '';
|
|
$style = $this->scaleBackground($style);
|
|
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
|
$values = $style[$device] ?? null;
|
|
if (!is_array($values)) {
|
|
continue;
|
|
}
|
|
$rules = '';
|
|
$base = $this->declarations($values, 'self', $device);
|
|
if ('' !== $base) {
|
|
$rules .= sprintf('#%s{%s}', $id, $base);
|
|
}
|
|
// Some properties belong on the images inside the block, not on the block: object-fit
|
|
// on a figure does nothing, and a radius on the figure leaves the image square.
|
|
$images = $this->declarations($values, 'img', $device);
|
|
if ('' !== $images) {
|
|
$rules .= sprintf('#%s img{%s}', $id, $images);
|
|
}
|
|
// Slider colours: on the slider for the script-free markers, and on the control bar
|
|
// the script places right after it.
|
|
$controls = $this->declarations($values, 'controls', $device);
|
|
if ('' !== $controls) {
|
|
$rules .= sprintf('#%s,#%s + .slider-controls{%s}', $id, $id, $controls);
|
|
}
|
|
// Der Menü-Knopf: die Farben liegen am Block, gelten aber dem aufklappbaren Knopf.
|
|
$burger = $this->declarations($values, 'burger', $device);
|
|
if ('' !== $burger) {
|
|
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
|
|
}
|
|
$hover = is_array($values['hover'] ?? null) ? $this->declarations($this->inheritShadow($values['hover'], $values), 'self', $device) : '';
|
|
if ('' !== $hover) {
|
|
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
|
}
|
|
// Das Symbol im Button (ADR 109): Farbe, Fläche, Größe und Drehung liegen am Button,
|
|
// gelten aber seiner Symbolhülle - auch beim Überfahren und bei Tastaturfokus.
|
|
$icon = $this->declarations($values, 'icon', $device);
|
|
if ('' !== $icon) {
|
|
$rules .= sprintf('#%s .button-icon{%s}', $id, $icon);
|
|
}
|
|
$iconHover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'icon', $device) : '';
|
|
if ('' !== $iconHover) {
|
|
$rules .= sprintf('#%s:hover .button-icon,#%s:focus-visible .button-icon{%s}', $id, $id, $iconHover);
|
|
}
|
|
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
|
|
// The change eases in, so a header does not snap from glass to solid.
|
|
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
|
|
if ('' !== $scrolled) {
|
|
$rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled);
|
|
}
|
|
if ('' === $rules) {
|
|
continue;
|
|
}
|
|
// Desktop first, narrower screens override it. Mobile last so it wins on small screens.
|
|
$css .= isset(self::BREAKPOINTS[$device])
|
|
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
|
|
: $rules;
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
|
|
/**
|
|
* Turns one validated value set into CSS declarations.
|
|
*
|
|
* @param string $target 'self' for the block, 'img' for the images inside it
|
|
*/
|
|
private function declarations(array $values, string $target = 'self', string $device = 'desktop'): string
|
|
{
|
|
$properties = StyleSchema::properties();
|
|
$out = [];
|
|
foreach ($values as $key => $value) {
|
|
if ('hover' === $key || !isset($properties[$key])) {
|
|
continue;
|
|
}
|
|
$property = $properties[$key];
|
|
if ($target !== ($property['target'] ?? 'self')) {
|
|
continue;
|
|
}
|
|
if (null === $property['css']) {
|
|
continue; // Composed below out of several values.
|
|
}
|
|
if ($property['corners'] ?? false) {
|
|
// border-radius becomes border-top-left-radius and friends.
|
|
foreach ($value as $corner => $single) {
|
|
$out[] = sprintf('border-%s-radius:%s', StyleSchema::cornerCss((string) $corner), $this->css($property, (string) $single));
|
|
}
|
|
continue;
|
|
}
|
|
if ($property['sides'] ?? false) {
|
|
// The spelling comes from the schema: "border-top-width", not "border-width-top".
|
|
$pattern = $property['sideCss'] ?? $property['css'].'-%s';
|
|
foreach ($value as $side => $single) {
|
|
$out[] = sprintf('%s:%s', sprintf($pattern, $side), $this->css($property, (string) $single));
|
|
}
|
|
continue;
|
|
}
|
|
$out[] = sprintf('%s:%s', $property['css'], $this->css($property, (string) $value));
|
|
}
|
|
// The composed declarations describe the block itself; none of them has an image variant.
|
|
if ('self' === $target) {
|
|
foreach ([
|
|
$this->background($values, $device),
|
|
$this->shadow($values),
|
|
$this->textShadow($values),
|
|
$this->filter($values),
|
|
$this->transform($values),
|
|
$this->grid($values),
|
|
$this->transition($values),
|
|
] as $composed) {
|
|
if ('' !== $composed) {
|
|
$out[] = $composed;
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode(';', $out);
|
|
}
|
|
|
|
private function css(array $property, string $value): string
|
|
{
|
|
if ('color' === $property['type']) {
|
|
return str_starts_with($value, '@') ? sprintf('var(--k-%s)', substr($value, 1)) : $value;
|
|
}
|
|
if ('font' === $property['type']) {
|
|
return sprintf('var(--kf-%s)', $value);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/** Overlay, gradient and image share one background-image declaration, layered in that order. */
|
|
private function background(array $values, string $device = 'desktop'): string
|
|
{
|
|
$layers = [];
|
|
$overlay = $values['overlayColor'] ?? null;
|
|
if (null !== $overlay) {
|
|
$colour = $this->css(['type' => 'color'], (string) $overlay);
|
|
$alpha = isset($values['overlayOpacity']) ? (float) $values['overlayOpacity'] : 0.5;
|
|
$layers[] = sprintf('linear-gradient(color-mix(in srgb,%s %d%%,transparent),color-mix(in srgb,%s %d%%,transparent))', $colour, (int) round($alpha * 100), $colour, (int) round($alpha * 100));
|
|
}
|
|
if (isset($values['gradientFrom'], $values['gradientTo'])) {
|
|
$layers[] = sprintf(
|
|
'linear-gradient(%ddeg,%s,%s)',
|
|
(int) ($values['gradientAngle'] ?? 180),
|
|
$this->css(['type' => 'color'], (string) $values['gradientFrom']),
|
|
$this->css(['type' => 'color'], (string) $values['gradientTo']),
|
|
);
|
|
}
|
|
if (isset($values['backgroundImage']) && 'none' !== $values['backgroundImage'] && null !== $this->media) {
|
|
$item = $this->media->find((string) $values['backgroundImage']);
|
|
if (null !== $item && [] !== $item['variants']) {
|
|
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
|
// srcset, but the rules are already written per device, so the device decides.
|
|
$largest = self::variantFor($item['variants'], $device);
|
|
// The URL comes from the media library, never from the document.
|
|
$layers[] = sprintf('url("%s")', $largest['src']);
|
|
}
|
|
}
|
|
|
|
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
|
}
|
|
|
|
/**
|
|
* Ein Zustand, der nur die Schattenfarbe aendert, erbt Versatz, Weichzeichnung und Ausdehnung
|
|
* vom Grundzustand - sonst hiesse er `box-shadow:0 0 0 0 <farbe>` und waere unsichtbar.
|
|
*/
|
|
private function inheritShadow(array $state, array $base): array
|
|
{
|
|
foreach (['shadow' => ['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowInset'], 'textShadow' => ['textShadowX', 'textShadowY', 'textShadowBlur']] as $colour => $parts) {
|
|
if (!isset($state[$colour.'Color']) && [] === array_intersect_key($state, array_flip($parts))) {
|
|
continue;
|
|
}
|
|
foreach ($parts as $part) {
|
|
if (!isset($state[$part]) && isset($base[$part])) {
|
|
$state[$part] = $base[$part];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $state;
|
|
}
|
|
|
|
private function shadow(array $values): string
|
|
{
|
|
$parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor']));
|
|
if ([] === $parts) {
|
|
return '';
|
|
}
|
|
|
|
return sprintf(
|
|
'box-shadow:%s%s %s %s %s %s',
|
|
'innen' === ($values['shadowInset'] ?? 'aussen') ? 'inset ' : '',
|
|
$values['shadowX'] ?? '0',
|
|
$values['shadowY'] ?? '0',
|
|
$values['shadowBlur'] ?? '0',
|
|
$values['shadowSpread'] ?? '0',
|
|
$this->css(['type' => 'color'], (string) ($values['shadowColor'] ?? '#00000026')),
|
|
);
|
|
}
|
|
|
|
/** A text shadow, from the same kind of parts as the box shadow. */
|
|
private function textShadow(array $values): string
|
|
{
|
|
$parts = array_intersect_key($values, array_flip(['textShadowX', 'textShadowY', 'textShadowBlur', 'textShadowColor']));
|
|
if ([] === $parts) {
|
|
return '';
|
|
}
|
|
|
|
return sprintf(
|
|
'text-shadow:%s %s %s %s',
|
|
$values['textShadowX'] ?? '0',
|
|
$values['textShadowY'] ?? '0',
|
|
$values['textShadowBlur'] ?? '0',
|
|
$this->css(['type' => 'color'], (string) ($values['textShadowColor'] ?? '#00000040')),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The image filters, as one declaration in a fixed order.
|
|
*
|
|
* The order matters for the result, so it is written here rather than taken from the order in
|
|
* which someone happened to set the values.
|
|
*/
|
|
private function filter(array $values): string
|
|
{
|
|
$parts = [];
|
|
foreach ([
|
|
'blur' => 'blur(%spx)',
|
|
'brightness' => 'brightness(%s)',
|
|
'contrast' => 'contrast(%s)',
|
|
'saturate' => 'saturate(%s)',
|
|
'grayscale' => 'grayscale(%s)',
|
|
'sepia' => 'sepia(%s)',
|
|
'invert' => 'invert(%s)',
|
|
] as $key => $format) {
|
|
if (isset($values[$key])) {
|
|
$parts[] = sprintf($format, (float) $values[$key]);
|
|
}
|
|
}
|
|
if (isset($values['hueRotate'])) {
|
|
$parts[] = sprintf('hue-rotate(%ddeg)', (int) $values['hueRotate']);
|
|
}
|
|
|
|
return [] === $parts ? '' : 'filter:'.implode(' ', $parts);
|
|
}
|
|
|
|
private function transform(array $values): string
|
|
{
|
|
$parts = [];
|
|
foreach (['translateX' => 'translateX(%s)', 'translateY' => 'translateY(%s)'] as $key => $format) {
|
|
if (isset($values[$key])) {
|
|
$parts[] = sprintf($format, $values[$key]);
|
|
}
|
|
}
|
|
if (isset($values['scale'])) {
|
|
$parts[] = sprintf('scale(%s)', (float) $values['scale']);
|
|
}
|
|
if (isset($values['rotate'])) {
|
|
$parts[] = sprintf('rotate(%ddeg)', (int) $values['rotate']);
|
|
}
|
|
|
|
return [] === $parts ? '' : 'transform:'.implode(' ', $parts);
|
|
}
|
|
|
|
/**
|
|
* The variant a device should load for a background.
|
|
*
|
|
* Mobile takes the smallest that is at least 960 wide, tablet the one around 1600, desktop the
|
|
* largest. A picture that only has small variants simply gives what it has.
|
|
*/
|
|
/**
|
|
* Ein Hintergrundbild gilt auch dann in Gerätegrößen, wenn es nur einmal gesetzt wurde.
|
|
*
|
|
* Wer im Editor ein Hintergrundbild wählt, setzt es üblicherweise am Schreibtisch - also unter
|
|
* „Desktop“. Ohne eigene Regel für die kleineren Geräte erbt ein Telefon diese Regel und lädt
|
|
* die größte Variante: gemessen 189 KB statt 40 KB für ein Bild, das nie in dieser Auflösung
|
|
* zu sehen ist. CSS kennt kein srcset, aber die Regeln stehen ohnehin je Gerät da - also
|
|
* bekommt jedes Gerät dieselbe Bildwahl, die es bekäme, wenn jemand sie einzeln gesetzt hätte.
|
|
*
|
|
* Ein ausdrücklich gesetztes Bild wird nie ersetzt; ergänzt wird nur, wo nichts steht.
|
|
*/
|
|
private function scaleBackground(array $style): array
|
|
{
|
|
$inherited = $style['desktop'] ?? [];
|
|
foreach (['tablet', 'mobile'] as $device) {
|
|
if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
|
|
$style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
|
|
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
|
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
|
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
|
if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
|
|
$style[$device][$begleiter] = $inherited[$begleiter];
|
|
}
|
|
}
|
|
}
|
|
$inherited = array_replace($inherited, $style[$device] ?? []);
|
|
}
|
|
|
|
return $style;
|
|
}
|
|
|
|
/**
|
|
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
|
|
*
|
|
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
|
|
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
|
|
*
|
|
* @param list<array{src: string, width: int}> $variants
|
|
*
|
|
* @return array{src: string, width: int}
|
|
*/
|
|
public static function variantFor(array $variants, string $device): array
|
|
{
|
|
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
|
|
$chosen = $variants[0];
|
|
foreach ($variants as $variant) {
|
|
if ($variant['width'] <= $wanted || $chosen['width'] < $wanted) {
|
|
$chosen = $variant;
|
|
}
|
|
}
|
|
|
|
return $chosen;
|
|
}
|
|
|
|
/**
|
|
* The column shares of a row: "2 1" becomes two tracks of 2fr and 1fr. Shares inherit from the
|
|
* wider device like every other style. A device whose layout stacks the columns - "one" from
|
|
* the classic tokens or a single grid column - gets one track instead: the desktop rule carries
|
|
* an id and would otherwise outrank the class that stacks the columns on a phone.
|
|
*/
|
|
private function columnTracks(string $id, array $style, array $layout, int $count): string
|
|
{
|
|
$css = $previous = '';
|
|
$widths = null;
|
|
$stacked = false;
|
|
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
|
$values = is_array($style[$device] ?? null) ? $style[$device] : [];
|
|
$widths = $values['columnWidths'] ?? $widths;
|
|
if (isset($values['gridColumns'])) {
|
|
$stacked = 1 === (int) $values['gridColumns'];
|
|
} elseif (isset($layout['design'][$device]['columns'])) {
|
|
$stacked = 'one' === $layout['design'][$device]['columns'];
|
|
}
|
|
if (null === $widths || $count < 2) {
|
|
continue;
|
|
}
|
|
$rule = sprintf('#%s{grid-template-columns:%s}', $id, $stacked ? 'minmax(0,1fr)' : $this->tracks((string) $widths, $count));
|
|
if ($rule === $previous) {
|
|
continue;
|
|
}
|
|
$css .= isset(self::BREAKPOINTS[$device])
|
|
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rule)
|
|
: $rule;
|
|
$previous = $rule;
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
|
|
/** One track per column: a column added later gets the average share, surplus shares are dropped. */
|
|
private function tracks(string $widths, int $count): string
|
|
{
|
|
$shares = array_values(array_filter(array_map(floatval(...), explode(' ', $widths)), static fn (float $share): bool => $share > 0));
|
|
$shares = array_slice($shares, 0, $count);
|
|
$average = [] === $shares ? 1.0 : array_sum($shares) / count($shares);
|
|
while (count($shares) < $count) {
|
|
$shares[] = $average;
|
|
}
|
|
$format = static fn (float $share): string => rtrim(rtrim(number_format($share, 1, '.', ''), '0'), '.');
|
|
|
|
return implode(' ', array_map(static fn (float $share): string => sprintf('minmax(0,%sfr)', $format($share)), $shares));
|
|
}
|
|
|
|
private function grid(array $values): string
|
|
{
|
|
return isset($values['gridColumns'])
|
|
? sprintf('grid-template-columns:repeat(%d,minmax(0,1fr))', max(1, min(12, (int) $values['gridColumns'])))
|
|
: '';
|
|
}
|
|
|
|
private function transition(array $values): string
|
|
{
|
|
return isset($values['transition'])
|
|
? sprintf('transition:all %dms ease', max(0, min(2000, (int) $values['transition'])))
|
|
: '';
|
|
}
|
|
|
|
private function require(bool $condition, string $message): void
|
|
{
|
|
if (!$condition) {
|
|
throw new InvalidDocument($message);
|
|
}
|
|
}
|
|
}
|