Umut on the DEEG hero image: "It should be rounded, but it was done from somewhere, and the corners look odd. And the editor has no image corner rounding at all, does it?" It did have one - per block, under Design > Bild > Bildecken. What it did not have was the 12px that enhancements.css set on .block-image for every installation: rounded on screen, present in no field, and only removable by overriding it on each block. On a template that is square throughout it shows, because the elements above the image - the gradient, the sign, the blue call to action - are square and stick out of the rounded corner. The radius is now theme.base.imageRadius, validated like the container width, emitted as --k-image-radius and used by the stylesheet. The default stays 12px so existing sites look the same; 0px makes every image square. The per-block setting still wins. DEEG is set to 0px, in the seed script and in the installation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
405 lines
20 KiB
PHP
405 lines
20 KiB
PHP
<?php
|
||
|
||
namespace App\Content;
|
||
|
||
use App\Domain\InvalidPage;
|
||
use Doctrine\DBAL\Connection;
|
||
|
||
/**
|
||
* The site wide design system: a free colour palette, font families and the base typography every
|
||
* page inherits. This is the "Haupt-Typografie" – set once here, valid everywhere, overridable on
|
||
* any block. Stored in the website row that has carried a theme column since the first migration.
|
||
*/
|
||
final class Theme
|
||
{
|
||
public const MAX_COLORS = 40;
|
||
public const MAX_FONTS = 8;
|
||
public const MAX_PRESETS = 40;
|
||
/** Weights Google serves for most families; also the range a designer realistically needs. */
|
||
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||
|
||
public const FALLBACKS = [
|
||
'sans-serif' => 'Serifenlos',
|
||
'serif' => 'Serifen',
|
||
'monospace' => 'Feste Breite',
|
||
'system-ui' => 'Systemschrift',
|
||
];
|
||
|
||
/**
|
||
* Fertige Paletten zum Übernehmen. Dieselben fünf Schlüssel wie die Voreinstellung, damit
|
||
* Grundfarben und Blöcke, die auf die Schlüssel ink oder link zeigen, stimmen nach dem Wechsel weiter.
|
||
* Die Linkfarbe erreicht auf dem Papierton jeweils mindestens 4,5:1 - eine Palette, die
|
||
* ihre eigenen Links unlesbar macht, wäre keine Hilfe.
|
||
*/
|
||
public const PALETTES = [
|
||
['name' => 'Wald', 'colors' => ['paper' => '#f6f7f4', 'ink' => '#203b34', 'mint' => '#e4efb9', 'accent' => '#b55b12', 'link' => '#a4510f']],
|
||
['name' => 'Ozean', 'colors' => ['paper' => '#f5f8fa', 'ink' => '#12283a', 'mint' => '#d6ecf3', 'accent' => '#0e7490', 'link' => '#0b5f78']],
|
||
['name' => 'Sand', 'colors' => ['paper' => '#faf6ef', 'ink' => '#3b2f24', 'mint' => '#f1e4cf', 'accent' => '#b8672a', 'link' => '#9a5320']],
|
||
['name' => 'Koralle', 'colors' => ['paper' => '#fffaf8', 'ink' => '#2b2320', 'mint' => '#ffe4dc', 'accent' => '#d9483b', 'link' => '#b83a2f']],
|
||
['name' => 'Schiefer', 'colors' => ['paper' => '#f4f5f7', 'ink' => '#1f2937', 'mint' => '#e2e8f0', 'accent' => '#4f46e5', 'link' => '#4338ca']],
|
||
['name' => 'Olive', 'colors' => ['paper' => '#f7f7f2', 'ink' => '#2a2f23', 'mint' => '#e6ead3', 'accent' => '#6b7a2e', 'link' => '#55621f']],
|
||
['name' => 'Nacht', 'colors' => ['paper' => '#14181d', 'ink' => '#eef1f4', 'mint' => '#262e38', 'accent' => '#7cc4ff', 'link' => '#9ad2ff']],
|
||
];
|
||
|
||
/** Die Namen der Palettenfarben, wie sie in einer neuen Palette heißen. */
|
||
public const COLOR_NAMES = ['paper' => 'Papier', 'ink' => 'Tinte', 'mint' => 'Mint', 'accent' => 'Akzent', 'link' => 'Links'];
|
||
|
||
public const DEFAULTS = [
|
||
'colors' => [
|
||
['key' => 'paper', 'name' => 'Papier', 'value' => '#f6f7f4'],
|
||
['key' => 'ink', 'name' => 'Tinte', 'value' => '#203b34'],
|
||
['key' => 'mint', 'name' => 'Mint', 'value' => '#e4efb9'],
|
||
['key' => 'accent', 'name' => 'Akzent', 'value' => '#b55b12'],
|
||
// Darker sibling of the accent: #b55b12 reaches only 4.37:1 on the light tones and
|
||
// would fail AA for normal text. This one passes on paper, mint and white.
|
||
['key' => 'link', 'name' => 'Links', 'value' => '#a4510f'],
|
||
],
|
||
'fonts' => [
|
||
['key' => 'body', 'name' => 'Fließtext', 'family' => '', 'source' => 'system', 'weights' => [400, 600], 'fallback' => 'system-ui'],
|
||
['key' => 'heading', 'name' => 'Überschriften', 'family' => '', 'source' => 'system', 'weights' => [500, 700], 'fallback' => 'system-ui'],
|
||
],
|
||
'base' => [
|
||
'fontFamily' => 'body',
|
||
'headingFontFamily' => 'heading',
|
||
'fontSize' => '18px',
|
||
'lineHeight' => 1.6,
|
||
'color' => '@ink',
|
||
'backgroundColor' => '@paper',
|
||
'linkColor' => '@link',
|
||
'containerWidth' => '1240px',
|
||
'headingScale' => 1.25,
|
||
// Die Ecken jedes Bildes auf der Website. Stand bis 1.20.2 fest im Stylesheet -
|
||
// sichtbar gerundet, aber in keinem Feld zu finden. 12px bleibt die Vorgabe,
|
||
// damit bestehende Websites gleich aussehen; 0px macht Bilder eckig.
|
||
'imageRadius' => '12px',
|
||
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
|
||
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
|
||
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
|
||
'linkUnderline' => true,
|
||
],
|
||
];
|
||
|
||
public function __construct(private Connection $db)
|
||
{
|
||
}
|
||
|
||
/** Remembered for one request, like the settings; dropped on every write. */
|
||
private ?array $loaded = null;
|
||
private bool $buttonsConfigured = false;
|
||
|
||
public function load(): array
|
||
{
|
||
if (null !== $this->loaded) {
|
||
return $this->loaded;
|
||
}
|
||
$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' => [], 'buttons' => ButtonDesign::defaults(self::DEFAULTS['base'])];
|
||
}
|
||
|
||
return $this->loaded = [
|
||
'colors' => $stored['colors'],
|
||
'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, ?array $buttons = null): void
|
||
{
|
||
$theme = [
|
||
'colors' => $this->colors($colors),
|
||
'fonts' => $this->fonts($fonts),
|
||
'base' => $this->base($base),
|
||
// 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];
|
||
if (str_starts_with($value, '@') && !in_array(substr($value, 1), $keys, true)) {
|
||
throw new InvalidPage(sprintf('Die Grundfarbe verweist auf „%s“, das es in der Palette nicht gibt.', substr($value, 1)));
|
||
}
|
||
}
|
||
foreach (['fontFamily', 'headingFontFamily'] as $field) {
|
||
if (!in_array($theme['base'][$field], array_column($theme['fonts'], 'key'), true)) {
|
||
throw new InvalidPage('Die Grundschrift verweist auf eine Schrift, die es nicht gibt.');
|
||
}
|
||
}
|
||
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
|
||
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'default',
|
||
]);
|
||
// After the write, never before: saving reads the theme again, and an early reset
|
||
// would only refill the memo with the state that is about to be replaced.
|
||
$this->loaded = null;
|
||
}
|
||
|
||
private function colors(array $input): array
|
||
{
|
||
$colors = [];
|
||
$seen = [];
|
||
foreach ($input as $row) {
|
||
$key = strtolower(trim((string) ($row['key'] ?? '')));
|
||
$value = trim((string) ($row['value'] ?? ''));
|
||
if ('' === $key) {
|
||
continue;
|
||
}
|
||
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/D', $key) || isset($seen[$key])) {
|
||
throw new InvalidPage('Jede Farbe braucht einen eindeutigen Schlüssel aus Kleinbuchstaben, Ziffern und Bindestrich.');
|
||
}
|
||
if (1 !== preg_match('/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/D', $value)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist kein Farbwert wie #1a2b3c.', $value));
|
||
}
|
||
$seen[$key] = true;
|
||
$colors[] = ['key' => $key, 'name' => mb_substr(trim((string) ($row['name'] ?? $key)), 0, 60) ?: $key, 'value' => strtolower($value)];
|
||
}
|
||
if ([] === $colors) {
|
||
throw new InvalidPage('Die Palette braucht mindestens eine Farbe.');
|
||
}
|
||
if (count($colors) > self::MAX_COLORS) {
|
||
throw new InvalidPage(sprintf('Mehr als %d Farben sind nicht vorgesehen.', self::MAX_COLORS));
|
||
}
|
||
|
||
return $colors;
|
||
}
|
||
|
||
private function fonts(array $input): array
|
||
{
|
||
$fonts = [];
|
||
$seen = [];
|
||
foreach ($input as $row) {
|
||
$key = strtolower(trim((string) ($row['key'] ?? '')));
|
||
if ('' === $key) {
|
||
continue;
|
||
}
|
||
if (1 !== preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $key) || isset($seen[$key])) {
|
||
throw new InvalidPage('Jede Schrift braucht einen eindeutigen Schlüssel aus Kleinbuchstaben, Ziffern und Bindestrich.');
|
||
}
|
||
$family = trim((string) ($row['family'] ?? ''));
|
||
// Only a family name, never a font stack: the fallback is chosen separately, so no
|
||
// quoting or comma can travel into the generated CSS.
|
||
if ('' !== $family && 1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9 ]{0,48}$/D', $family)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist kein einfacher Schriftname.', $family));
|
||
}
|
||
$source = (string) ($row['source'] ?? 'system');
|
||
if (!in_array($source, ['system', 'google'], true)) {
|
||
throw new InvalidPage('Als Quelle sind Systemschrift und Google Fonts vorgesehen.');
|
||
}
|
||
if ('google' === $source && '' === $family) {
|
||
throw new InvalidPage('Für eine Google-Schrift wird ein Name benötigt, zum Beispiel Inter.');
|
||
}
|
||
$fallback = (string) ($row['fallback'] ?? 'system-ui');
|
||
if (!isset(self::FALLBACKS[$fallback])) {
|
||
throw new InvalidPage('Unbekannte Ersatzschrift.');
|
||
}
|
||
$weights = array_values(array_intersect(self::WEIGHTS, array_map('intval', (array) ($row['weights'] ?? [400]))));
|
||
$seen[$key] = true;
|
||
$fonts[] = [
|
||
'key' => $key,
|
||
'name' => mb_substr(trim((string) ($row['name'] ?? $key)), 0, 60) ?: $key,
|
||
'family' => $family,
|
||
'source' => $source,
|
||
'weights' => [] === $weights ? [400] : $weights,
|
||
'fallback' => $fallback,
|
||
];
|
||
}
|
||
if ([] === $fonts) {
|
||
throw new InvalidPage('Es wird mindestens eine Schrift benötigt.');
|
||
}
|
||
if (count($fonts) > self::MAX_FONTS) {
|
||
throw new InvalidPage(sprintf('Mehr als %d Schriften sind nicht vorgesehen.', self::MAX_FONTS));
|
||
}
|
||
|
||
return $fonts;
|
||
}
|
||
|
||
private function base(array $input): array
|
||
{
|
||
$base = [...self::DEFAULTS['base']];
|
||
foreach (['fontFamily', 'headingFontFamily'] as $field) {
|
||
$base[$field] = strtolower(trim((string) ($input[$field] ?? $base[$field])));
|
||
}
|
||
foreach (['color', 'backgroundColor', 'linkColor'] as $field) {
|
||
$value = trim((string) ($input[$field] ?? $base[$field]));
|
||
if (1 !== preg_match(StyleSchema::COLOR, $value)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist kein Farbwert.', $value));
|
||
}
|
||
$base[$field] = $value;
|
||
}
|
||
foreach (['fontSize', 'containerWidth', 'imageRadius'] as $field) {
|
||
$value = trim((string) ($input[$field] ?? $base[$field]));
|
||
if (1 !== preg_match(StyleSchema::LENGTH, $value)) {
|
||
throw new InvalidPage(sprintf('„%s“ ist keine Größenangabe wie 18px oder 70rem.', $value));
|
||
}
|
||
$base[$field] = $value;
|
||
}
|
||
$base['linkUnderline'] = (bool) ($input['linkUnderline'] ?? false);
|
||
foreach (['lineHeight' => [0.8, 3.0], 'headingScale' => [1.0, 2.0]] as $field => [$min, $max]) {
|
||
$value = (float) ($input[$field] ?? $base[$field]);
|
||
if ($value < $min || $value > $max) {
|
||
throw new InvalidPage(sprintf('„%s“ liegt außerhalb des zulässigen Bereichs.', $field));
|
||
}
|
||
$base[$field] = $value;
|
||
}
|
||
|
||
return $base;
|
||
}
|
||
|
||
/**
|
||
* Stores a reusable style under a name, or removes it when the style is null. The style is
|
||
* validated with the same compiler the documents use, so a preset can never carry a value a
|
||
* block would be refused for.
|
||
*/
|
||
public function savePreset(string $name, ?array $style, StyleCompiler $compiler): void
|
||
{
|
||
$name = trim($name);
|
||
if ('' === $name || mb_strlen($name) > 60) {
|
||
throw new InvalidPage('Eine Vorlage braucht einen Namen mit höchstens 60 Zeichen.');
|
||
}
|
||
$key = trim(preg_replace('/[^a-z0-9]+/', '-', mb_strtolower($name, 'UTF-8')) ?? '', '-');
|
||
if ('' === $key) {
|
||
throw new InvalidPage('Aus diesem Namen lässt sich kein Schlüssel bilden.');
|
||
}
|
||
if (null !== $style) {
|
||
$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) {
|
||
throw new InvalidPage(sprintf('Mehr als %d Vorlagen sind nicht vorgesehen.', self::MAX_PRESETS));
|
||
}
|
||
$presets[] = ['key' => $key, 'name' => $name, 'style' => $style];
|
||
}
|
||
usort($presets, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
|
||
$this->db->executeStatement('UPDATE website SET theme = ? WHERE id = ?', [
|
||
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||
'default',
|
||
]);
|
||
// After the write, never before: saving reads the theme again, and an early reset
|
||
// would only refill the memo with the state that is about to be replaced.
|
||
$this->loaded = null;
|
||
}
|
||
|
||
/** The stylesheet every page carries: palette variables, font variables and base typography. */
|
||
/**
|
||
* Eine Farbe aus den Einstellungen als CSS-Wert: `@schlüssel` wird zur Palettenvariablen,
|
||
* ein Hexwert bleibt, und leer heißt "nicht gesetzt" - dann gilt der Standard des Stylesheets.
|
||
* Ein Schlüssel, den die Palette nicht kennt, wird ebenfalls zu null: lieber der Standard als
|
||
* eine Variable, die auf nichts zeigt.
|
||
*/
|
||
public static function cssColor(string $value, array $theme): ?string
|
||
{
|
||
if ('' === $value) {
|
||
return null;
|
||
}
|
||
if (!str_starts_with($value, '@')) {
|
||
return $value;
|
||
}
|
||
foreach ($theme['colors'] as $color) {
|
||
if ($color['key'] === substr($value, 1)) {
|
||
return sprintf('var(--k-%s)', $color['key']);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Die relative Leuchtdichte einer Hexfarbe nach WCAG; 0 fuer Schwarz, 1 fuer Weiss, 0.5 wenn unlesbar. */
|
||
public static function luminance(string $hex): float
|
||
{
|
||
if (1 !== preg_match('/^#([0-9a-f]{6})/i', $hex, $m)) {
|
||
return 0.5;
|
||
}
|
||
$kanal = static function (int $wert): float {
|
||
$c = $wert / 255;
|
||
|
||
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
|
||
};
|
||
|
||
return 0.2126 * $kanal(hexdec(substr($m[1], 0, 2))) + 0.7152 * $kanal(hexdec(substr($m[1], 2, 2))) + 0.0722 * $kanal(hexdec(substr($m[1], 4, 2)));
|
||
}
|
||
|
||
public function css(array $theme): string
|
||
{
|
||
$variables = [];
|
||
foreach ($theme['colors'] as $color) {
|
||
$variables[] = sprintf('--k-%s:%s', $color['key'], $color['value']);
|
||
}
|
||
foreach ($theme['fonts'] as $font) {
|
||
$stack = '' === $font['family'] ? $font['fallback'] : sprintf('"%s",%s', $font['family'], $font['fallback']);
|
||
$variables[] = sprintf('--kf-%s:%s', $font['key'], $stack);
|
||
}
|
||
$base = $theme['base'];
|
||
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
|
||
$variables[] = sprintf('--k-image-radius:%s', $base['imageRadius']);
|
||
|
||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||
|
||
// Die Bausteine selbst - Knopf, Symbol, Rahmen, ruhiger Text - wurden aus festen Werten in
|
||
// site.css gefaerbt. Eine Website mit eigener Palette behielt damit das Gruen der Vorlage:
|
||
// die Farben liessen sich einstellen und taten nichts. Hier bekommen diese Token ihre
|
||
// Werte aus dem Theme. Als Akzent dient die Linkfarbe und nicht ein Palettenname, weil
|
||
// dieses Token auch kleine Beschriftungen faerbt - eine helle Akzentfarbe waere dort
|
||
// nicht mehr lesbar.
|
||
$ink = $colour($base['color']);
|
||
$paper = $colour($base['backgroundColor']);
|
||
$variables[] = sprintf('--ink:%s', $ink);
|
||
$variables[] = sprintf('--paper:%s', $paper);
|
||
$variables[] = sprintf('--accent:%s', $colour($base['linkColor']));
|
||
// Abgeleitet statt erfunden: ruhiger Text und Linien sind der Textton, in den Hintergrund
|
||
// gemischt. Ein Palettenwert gleichen Namens hat Vorrang, damit sich beides setzen laesst.
|
||
// 80 Prozent, nicht weniger: bei 72 kam fuer eine Palette mit hellem Textton ein Grau
|
||
// heraus, das die 4,5:1 knapp verfehlte - gefunden beim Nachbau von kant-it.de.
|
||
$variables[] = sprintf('--muted:var(--k-muted,color-mix(in srgb,%s 80%%,%s))', $ink, $paper);
|
||
$variables[] = sprintf('--line:var(--k-line,color-mix(in srgb,%s 20%%,%s))', $ink, $paper);
|
||
$variables[] = sprintf('--mint:var(--k-mint,color-mix(in srgb,%s 14%%,%s))', $colour($base['linkColor']), $paper);
|
||
// Dunkle Palette: Formularfelder, Rollbalken und Fehlermeldungen folgen ihr. Das Rot fuer
|
||
// Fehler war fest auf Weiss gedacht; auf dunklem Grund ist es unlesbar. Also je nach
|
||
// Helligkeit des Papiers ein Rot, das dort steht - und der Hintergrund der Meldung aus
|
||
// Rot und Papier gemischt statt aus einem festen Hellrosa.
|
||
// Fuer die Leuchtdichte braucht es den Hexwert, nicht den Verweis var(--k-...).
|
||
$hexwert = static fn (string $wert): string => str_starts_with($wert, '@') ? (string) (array_column($theme['colors'], 'value', 'key')[substr($wert, 1)] ?? '#ffffff') : $wert;
|
||
$dunkel = self::luminance($hexwert($base['backgroundColor'])) < 0.4;
|
||
$variables[] = '--k-scheme:'.($dunkel ? 'dark' : 'light');
|
||
$variables[] = '--k-error:'.($dunkel ? '#ff9b8a' : '#8b2f22');
|
||
$variables[] = sprintf('--k-error-bg:color-mix(in srgb,%s 12%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
|
||
$variables[] = sprintf('--k-error-line:color-mix(in srgb,%s 45%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
|
||
|
||
$scale = (float) $base['headingScale'];
|
||
$headings = '';
|
||
// A modular scale from the base size, so one number changes the whole hierarchy.
|
||
foreach ([1 => 4, 2 => 3, 3 => 2, 4 => 1, 5 => 0, 6 => -1] as $level => $step) {
|
||
$headings .= sprintf('.kant h%d{font-size:calc(var(--k-size) * %.4f)}', $level, $scale ** $step);
|
||
}
|
||
|
||
return sprintf(
|
||
':root{%s;--k-size:%s}.kant{font-family:var(--kf-%s);font-size:var(--k-size);line-height:%s;color:%s;background:%s}'
|
||
.'.kant h1,.kant h2,.kant h3,.kant h4,.kant h5,.kant h6{font-family:var(--kf-%s)}'
|
||
.'.kant .block-text a,.kant .block-list a,.kant .post-excerpt a{color:%s;text-decoration:%s}'
|
||
.'.kant .block-text a:hover,.kant .block-list a:hover,.kant .post-excerpt a:hover{text-decoration:underline}'
|
||
.'.kant .shell{max-width:var(--k-container)}%s',
|
||
implode(';', $variables),
|
||
$base['fontSize'],
|
||
$base['fontFamily'],
|
||
$base['lineHeight'],
|
||
$colour($base['color']),
|
||
$colour($base['backgroundColor']),
|
||
$base['headingFontFamily'],
|
||
$colour($base['linkColor']),
|
||
($base['linkUnderline'] ?? true) ? 'underline' : 'none',
|
||
$headings,
|
||
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
|
||
}
|
||
}
|