Files
KantSites/src/Content/Theme.php
T
kantarciandClaude Opus 5 23fdca4edb Move image corners into the design area
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>
2026-09-16 09:18:27 +02:00

405 lines
20 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;
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));
}
}