Freie Gestaltung statt Tokenlisten (Dokumentschema v10)
Der bisherige Editor bot drei Farben, keine Schriftwahl und elf Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit begründet - der Schluss war falsch: die Alternative zu beliebigem CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes Designmodell. StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe Definition, also kann der Editor nichts anbieten, was der Server ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je Gerät und für den Hover-Zustand, mit Vererbung und einzelnem Zurücksetzen. Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text; zusammengesetzte Deklarationen baut der Compiler selbst. Der Server erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline. Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften aus Systemschrift oder Google Fonts, Grundtypografie mit modularer Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung; die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt ist. Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter "Klassische Werte dieser Seite" bearbeitbar. Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand ausgeführt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a40c2bb27e
commit
abf0bd2c6b
24 files changed
+1591
-21
No files matched your search
@@ -0,0 +1,254 @@
|
||||
<?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;
|
||||
/** 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',
|
||||
];
|
||||
|
||||
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'],
|
||||
],
|
||||
'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' => '@accent',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
|
||||
return self::DEFAULTS;
|
||||
}
|
||||
|
||||
return ['colors' => $stored['colors'], 'fonts' => $stored['fonts'], 'base' => [...self::DEFAULTS['base'], ...$stored['base']]];
|
||||
}
|
||||
|
||||
public function save(array $colors, array $fonts, array $base): void
|
||||
{
|
||||
$theme = [
|
||||
'colors' => $this->colors($colors),
|
||||
'fonts' => $this->fonts($fonts),
|
||||
'base' => $this->base($base),
|
||||
];
|
||||
$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',
|
||||
]);
|
||||
}
|
||||
|
||||
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 && '' === $value) {
|
||||
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'] 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** The stylesheet every page carries: palette variables, font variables and base typography. */
|
||||
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']);
|
||||
|
||||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||||
$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 a{color:%s}.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']),
|
||||
$headings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google Fonts stylesheet for every family that comes from there, or null when none does.
|
||||
* Chosen by the user; it means one request to a third party per page view.
|
||||
*/
|
||||
public function googleFontsUrl(array $theme): ?string
|
||||
{
|
||||
$families = [];
|
||||
foreach ($theme['fonts'] as $font) {
|
||||
if ('google' === $font['source'] && '' !== $font['family']) {
|
||||
$families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights']));
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user