Add visual button presets, jump targets and menu controls
This commit is contained in:
1 parent
bb4e422af8
commit
3391fea58b
41 files changed
+723
-57
No files matched your search
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
|
||||
/** Shared vocabulary for global button defaults and the responsive block variants. */
|
||||
final class ButtonDesign
|
||||
{
|
||||
public const OPTIONS = [
|
||||
'shape' => ['square' => 'Eckig', 'rounded' => 'Abgerundet', 'pill' => 'Pille'],
|
||||
'appearance' => ['solid' => 'Gefüllt', 'outline' => 'Rahmen', 'soft' => 'Dezent', 'link' => 'Textlink'],
|
||||
'size' => ['small' => 'Klein', 'normal' => 'Normal', 'large' => 'Groß'],
|
||||
];
|
||||
|
||||
public const PRESETS = [
|
||||
['name' => 'Eckig', 'shape' => 'square', 'appearance' => 'solid'],
|
||||
['name' => 'Abgerundet', 'shape' => 'rounded', 'appearance' => 'solid'],
|
||||
['name' => 'Pille', 'shape' => 'pill', 'appearance' => 'solid'],
|
||||
['name' => 'Rahmen · eckig', 'shape' => 'square', 'appearance' => 'outline'],
|
||||
['name' => 'Rahmen · rund', 'shape' => 'rounded', 'appearance' => 'outline'],
|
||||
['name' => 'Rahmen · Pille', 'shape' => 'pill', 'appearance' => 'outline'],
|
||||
['name' => 'Dezent', 'shape' => 'rounded', 'appearance' => 'soft'],
|
||||
['name' => 'Textlink', 'shape' => 'square', 'appearance' => 'link'],
|
||||
];
|
||||
|
||||
public static function defaults(array $base): array
|
||||
{
|
||||
return ['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit',
|
||||
'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none',
|
||||
'backgroundColor' => $base['color'], 'color' => $base['backgroundColor'],
|
||||
'hoverBackgroundColor' => $base['color'], 'hoverColor' => $base['backgroundColor']];
|
||||
}
|
||||
|
||||
public static function validate(array $input, array $base, array $colors, array $fonts): array
|
||||
{
|
||||
$value = [...self::defaults($base), ...$input];
|
||||
foreach ([...self::OPTIONS, 'textTransform' => ['none' => '', 'uppercase' => '']] as $key => $options) {
|
||||
if (!is_string($value[$key]) || !isset($options[$value[$key]])) {
|
||||
throw new InvalidPage('Unbekannte Button-Einstellung: '.$key);
|
||||
}
|
||||
}
|
||||
foreach (['fontSize' => [10, 72], 'fontWeight' => [100, 900], 'letterSpacing' => [-1, 8]] as $key => [$min, $max]) {
|
||||
if (!is_numeric($value[$key]) || $value[$key] < $min || $value[$key] > $max) {
|
||||
throw new InvalidPage('Bitte prüfe den Button-Wert: '.$key);
|
||||
}
|
||||
$value[$key] = (float) $value[$key];
|
||||
}
|
||||
if (!in_array((int) $value['fontWeight'], Theme::WEIGHTS, true) || $value['fontWeight'] != (int) $value['fontWeight']) {
|
||||
throw new InvalidPage('Unbekannte Button-Schriftstärke.');
|
||||
}
|
||||
if (!in_array($value['fontFamily'], ['inherit', ...array_column($fonts, 'key')], true)) {
|
||||
throw new InvalidPage('Die Button-Schrift ist nicht vorhanden.');
|
||||
}
|
||||
foreach (['backgroundColor', 'color', 'hoverBackgroundColor', 'hoverColor'] as $key) {
|
||||
if (!is_string($value[$key]) || 1 !== preg_match(StyleSchema::COLOR, $value[$key])
|
||||
|| (str_starts_with($value[$key], '@') && !in_array(substr($value[$key], 1), array_column($colors, 'key'), true))) {
|
||||
throw new InvalidPage('Bitte prüfe die Button-Farbe: '.$key);
|
||||
}
|
||||
}
|
||||
|
||||
return array_intersect_key($value, self::defaults($base));
|
||||
}
|
||||
|
||||
/** Palette references stay live; block variants use these same variables. */
|
||||
public static function css(array $buttons): string
|
||||
{
|
||||
$color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v;
|
||||
$out = [
|
||||
'--kb-fill' => $color($buttons['backgroundColor']), '--kb-ink' => $color($buttons['color']),
|
||||
'--kb-hover-fill' => $color($buttons['hoverBackgroundColor']), '--kb-hover-ink' => $color($buttons['hoverColor']),
|
||||
'--kb-font' => 'inherit' === $buttons['fontFamily'] ? 'inherit' : 'var(--kf-'.$buttons['fontFamily'].')',
|
||||
'--kb-font-size' => $buttons['fontSize'].'px', '--kb-weight' => $buttons['fontWeight'],
|
||||
'--kb-tracking' => $buttons['letterSpacing'].'px', '--kb-transform' => $buttons['textTransform'],
|
||||
'--kb-radius' => ['square' => '0px', 'rounded' => '8px', 'pill' => '999px'][$buttons['shape']],
|
||||
'--kb-py' => ['small' => '10px', 'normal' => '15px', 'large' => '19px'][$buttons['size']],
|
||||
'--kb-px' => ['small' => '16px', 'normal' => '21px', 'large' => '30px'][$buttons['size']],
|
||||
];
|
||||
foreach (self::appearance($buttons['appearance']) as $key => $value) {
|
||||
$out['--kb-'.$key] = $value;
|
||||
}
|
||||
|
||||
return ':root{'.implode(';', array_map(static fn ($key, $value): string => $key.':'.$value, array_keys($out), $out)).'}';
|
||||
}
|
||||
|
||||
public static function appearance(string $name): array
|
||||
{
|
||||
return match ($name) {
|
||||
'outline' => ['bg' => 'transparent', 'fg' => 'var(--kb-fill)', 'border' => 'var(--kb-fill)', 'hover-bg' => 'var(--kb-fill)', 'hover-fg' => 'var(--kb-ink)'],
|
||||
'soft' => ['bg' => 'color-mix(in srgb,var(--kb-fill) 12%,transparent)', 'fg' => 'var(--kb-fill)', 'border' => 'transparent', 'hover-bg' => 'var(--kb-fill)', 'hover-fg' => 'var(--kb-ink)'],
|
||||
'link' => ['bg' => 'transparent', 'fg' => 'var(--kb-fill)', 'border' => 'transparent', 'hover-bg' => 'transparent', 'hover-fg' => 'var(--kb-hover-fill)'],
|
||||
default => ['bg' => 'var(--kb-fill)', 'fg' => 'var(--kb-ink)', 'border' => 'transparent', 'hover-bg' => 'var(--kb-hover-fill)', 'hover-fg' => 'var(--kb-hover-ink)'],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -52,6 +52,16 @@ final class DocumentValidator
|
||||
foreach ($document['blocks'] as $block) {
|
||||
$this->block($block, $ids, $headings, 0, $schemaVersion, null);
|
||||
}
|
||||
$anchors = function (array $blocks) use (&$anchors, &$ids): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (isset($block['anchor'])) {
|
||||
$this->require(!isset($ids[$block['anchor']]) && !in_array($block['anchor'], ['main', 'top', 'admin-main'], true), 'Dieses Sprungziel ist bereits belegt. Bitte einen eindeutigen Namen wählen.');
|
||||
$ids[$block['anchor']] = true;
|
||||
}
|
||||
$anchors($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$anchors($document['blocks']);
|
||||
$this->require(
|
||||
$requireHeading ? 1 === $headings : 0 === $headings,
|
||||
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
|
||||
@@ -65,6 +75,11 @@ final class DocumentValidator
|
||||
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.');
|
||||
$ids[$id] = true;
|
||||
$type = $block['type'] ?? null;
|
||||
if (isset($block['anchor'])) {
|
||||
$this->require($schemaVersion >= 21 && in_array($type, ['section', 'row', 'column'], true)
|
||||
&& is_string($block['anchor']) && 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $block['anchor']),
|
||||
'Ein Sprungziel braucht einen Bereich und einen Namen wie kontakt (ohne #), Schema v21.');
|
||||
}
|
||||
// From schema v4 on, a namespaced type belongs to a plugin. The core does not know its
|
||||
// property schema, so it keeps the block untouched instead of rejecting or dropping the
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
@@ -96,6 +111,16 @@ final class DocumentValidator
|
||||
$layout = $block['layout'] ?? [];
|
||||
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
||||
foreach ($layout as $key => $value) {
|
||||
if ('button' === $key) {
|
||||
$this->require($schemaVersion >= 21 && 'button' === $type && is_array($value), 'Button-Varianten brauchen einen Button und Schema v21.');
|
||||
foreach ($value as $device => $settings) {
|
||||
$this->require(isset(StyleSchema::DEVICES[$device]) && is_array($settings), 'Unbekanntes Button-Gerät.');
|
||||
foreach ($settings as $setting => $token) {
|
||||
$this->require(is_string($token) && isset(ButtonDesign::OPTIONS[$setting][$token]), 'Unbekannte Button-Variante.');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ('effects' === $key && $schemaVersion >= 3) {
|
||||
$this->require(is_array($value), 'Effects must be an object.');
|
||||
$effects = [
|
||||
|
||||
@@ -132,7 +132,7 @@ final class MenuRepository
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])];
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])];
|
||||
}
|
||||
|
||||
return $links;
|
||||
@@ -156,6 +156,12 @@ final class MenuRepository
|
||||
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
||||
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
if (isset($item['icon']) && (!is_string($item['icon']) || !in_array($item['icon'], ['', ...DocumentValidator::ICONS], true))) {
|
||||
throw new InvalidMenu('Unbekanntes Menü-Icon.');
|
||||
}
|
||||
if (isset($item['iconPosition']) && !in_array($item['iconPosition'], ['before', 'after'], true)) {
|
||||
throw new InvalidMenu('Ein Icon steht vor oder nach dem Text.');
|
||||
}
|
||||
if ('page' === ($item['type'] ?? null)) {
|
||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
||||
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
|
||||
@@ -183,8 +189,9 @@ final class MenuRepository
|
||||
$allowed = $allowed || (str_starts_with($url, '/') && !str_starts_with($url, '//'));
|
||||
$allowed = $allowed || 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $url);
|
||||
$allowed = $allowed || 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $url);
|
||||
$allowed = $allowed || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $url);
|
||||
if (!$allowed) {
|
||||
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade sowie mailto: und tel:.');
|
||||
throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade, Sprungziele wie #kontakt sowie mailto: und tel:.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace App\Content;
|
||||
final class StyleBundle
|
||||
{
|
||||
/** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */
|
||||
private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css'];
|
||||
private const SOURCES = ['fonts/fonts.css', 'site.css', 'buttons.css', 'design.css', 'enhancements.css'];
|
||||
|
||||
public function __construct(private string $assetDirectory)
|
||||
{
|
||||
|
||||
+13
-4
@@ -62,6 +62,7 @@ final class Theme
|
||||
|
||||
/** Remembered for one request, like the settings; dropped on every write. */
|
||||
private ?array $loaded = null;
|
||||
private bool $buttonsConfigured = false;
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
@@ -70,8 +71,9 @@ final class Theme
|
||||
}
|
||||
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$this->buttonsConfigured = is_array($stored['buttons'] ?? null);
|
||||
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
|
||||
return $this->loaded = [...self::DEFAULTS, 'presets' => []];
|
||||
return $this->loaded = [...self::DEFAULTS, 'presets' => [], 'buttons' => ButtonDesign::defaults(self::DEFAULTS['base'])];
|
||||
}
|
||||
|
||||
return $this->loaded = [
|
||||
@@ -79,10 +81,11 @@ final class Theme
|
||||
'fonts' => $stored['fonts'],
|
||||
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
|
||||
'presets' => is_array($stored['presets'] ?? null) ? $stored['presets'] : [],
|
||||
'buttons' => [...ButtonDesign::defaults([...self::DEFAULTS['base'], ...$stored['base']]), ...($stored['buttons'] ?? [])],
|
||||
];
|
||||
}
|
||||
|
||||
public function save(array $colors, array $fonts, array $base): void
|
||||
public function save(array $colors, array $fonts, array $base, ?array $buttons = null): void
|
||||
{
|
||||
$theme = [
|
||||
'colors' => $this->colors($colors),
|
||||
@@ -91,6 +94,9 @@ final class Theme
|
||||
// Saving the palette must not drop the presets an editor has collected.
|
||||
'presets' => $this->load()['presets'],
|
||||
];
|
||||
if (null !== $buttons || $this->buttonsConfigured) {
|
||||
$theme['buttons'] = ButtonDesign::validate($buttons ?? $this->load()['buttons'], $theme['base'], $theme['colors'], $theme['fonts']);
|
||||
}
|
||||
$keys = array_column($theme['colors'], 'key');
|
||||
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
|
||||
$value = $theme['base'][$field];
|
||||
@@ -119,7 +125,7 @@ final class Theme
|
||||
foreach ($input as $row) {
|
||||
$key = strtolower(trim((string) ($row['key'] ?? '')));
|
||||
$value = trim((string) ($row['value'] ?? ''));
|
||||
if ('' === $key && '' === $value) {
|
||||
if ('' === $key) {
|
||||
continue;
|
||||
}
|
||||
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/D', $key) || isset($seen[$key])) {
|
||||
@@ -242,6 +248,9 @@ final class Theme
|
||||
$compiler->validate($style);
|
||||
}
|
||||
$theme = $this->load();
|
||||
if (!$this->buttonsConfigured) {
|
||||
unset($theme['buttons']);
|
||||
}
|
||||
$presets = array_values(array_filter($theme['presets'], static fn (array $p): bool => $p['key'] !== $key));
|
||||
if (null !== $style) {
|
||||
if (count($presets) >= self::MAX_PRESETS) {
|
||||
@@ -317,7 +326,7 @@ final class Theme
|
||||
$colour($base['linkColor']),
|
||||
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
|
||||
$headings,
|
||||
);
|
||||
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user