Make the consent notice styleable and keep the back-to-top button reachable in 1.14.1
The back-to-top button was hidden as long as the consent question was open, on every device - and on a phone the question tends to stay open. Instead of `display:none` it now anchors itself 12px above the bar wherever the bar covers its corner: below 800px for the centred card, at every width for the full-width bar and the corner card. Browsers without anchor positioning keep hiding it there. The bar renders before the button so it can be an anchor, and the reserved bottom padding grows to 320px below 620px, where the bar with its default text is up to 299px tall. The notice itself gets a face. Settings → Einwilligung now carries layout (card, bar, corner), shape (soft, round, square - one setting for the box and its buttons), a heading and four colours, each as #hex or @palette key. `Theme::cssColor()` resolves both, replacing the copy inside the back-to-top code; `Consent::styles()` writes the values as `--kant-consent-*` into the hashed style block. The base layout changes with it: an accent stripe and shield icon, equal-width buttons, a disclosure with a chevron, underlined legal links, a two-column bar on wide screens. Declining stays an outlined button of the same size next to accepting. The archive and the consent page carry the same variables - the archive had no palette at all until now. In the editor, rows with an own value are labelled in orange rather than bold with a dot; the device chips sit 6px apart with the focus ring inside the gap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
860bed9199
commit
0bdcc7f90e
18 files changed
+300
-56
No files matched your search
@@ -172,8 +172,39 @@ final class Consent
|
||||
'decided' => null !== $decision,
|
||||
'decidedAt' => $decision['at'] ?? null,
|
||||
'text' => $settings['consentText'],
|
||||
'title' => $settings['consentTitle'],
|
||||
'layout' => $settings['consentLayout'],
|
||||
'privacyPath' => $settings['privacyPath'],
|
||||
'imprintPath' => $settings['imprintPath'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gestaltung des Hinweises als CSS-Variablen für den Kopf der Seite.
|
||||
*
|
||||
* Das Stylesheet kennt die Standardwerte; hier stehen nur die Abweichungen, die jemand in den
|
||||
* Einstellungen gewählt hat. Die Form bestimmt zwei Radien auf einmal - Fläche und Knöpfe
|
||||
* gehören zusammen, und zwei Regler dafür wären zwei Regler, die man gegeneinander verstellt.
|
||||
*/
|
||||
public function styles(array $theme): string
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings['consentBanner']) {
|
||||
return '';
|
||||
}
|
||||
[$box, $button] = match ($settings['consentShape']) {
|
||||
'rund' => ['28px', '999px'],
|
||||
'eckig' => ['0', '0'],
|
||||
default => ['18px', '10px'],
|
||||
};
|
||||
$values = ['--kant-consent-radius:'.$box, '--kant-consent-button-radius:'.$button];
|
||||
foreach (['consentColor' => '--kant-consent-bg', 'consentInk' => '--kant-consent-ink', 'consentAccent' => '--kant-consent-accent', 'consentAccentInk' => '--kant-consent-accent-ink'] as $field => $variable) {
|
||||
$value = Theme::cssColor((string) $settings[$field], $theme);
|
||||
if (null !== $value) {
|
||||
$values[] = $variable.':'.$value;
|
||||
}
|
||||
}
|
||||
|
||||
return ':root{'.implode(';', $values).'}';
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,14 @@ final class SiteSettings
|
||||
'consentBanner' => false,
|
||||
'consentGroups' => ['external'],
|
||||
'consentText' => '',
|
||||
// Sein Aussehen: Anordnung, Form, Farben, Überschrift. Leer heißt Theme-Standard.
|
||||
'consentLayout' => 'karte',
|
||||
'consentShape' => 'weich',
|
||||
'consentTitle' => '',
|
||||
'consentColor' => '',
|
||||
'consentInk' => '',
|
||||
'consentAccent' => '',
|
||||
'consentAccentInk' => '',
|
||||
'privacyPath' => '',
|
||||
'imprintPath' => '',
|
||||
];
|
||||
@@ -303,6 +311,16 @@ final class SiteSettings
|
||||
array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])),
|
||||
));
|
||||
$values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600);
|
||||
$values['consentLayout'] = in_array($input['consentLayout'] ?? '', ['karte', 'leiste', 'ecke'], true) ? $input['consentLayout'] : 'karte';
|
||||
$values['consentShape'] = in_array($input['consentShape'] ?? '', ['weich', 'rund', 'eckig'], true) ? $input['consentShape'] : 'weich';
|
||||
$values['consentTitle'] = mb_substr(trim((string) ($input['consentTitle'] ?? '')), 0, 80);
|
||||
foreach (['consentColor', 'consentInk', 'consentAccent', 'consentAccentInk'] as $feld) {
|
||||
$farbe = trim((string) ($input[$feld] ?? ''));
|
||||
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
|
||||
throw new InvalidPage('Die Farben des Einwilligungshinweises werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
|
||||
}
|
||||
$values[$feld] = $farbe;
|
||||
}
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
$path = trim(trim((string) ($input[$key] ?? '')), '/');
|
||||
if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) {
|
||||
|
||||
@@ -269,6 +269,29 @@ final class Theme
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
public function css(array $theme): string
|
||||
{
|
||||
$variables = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -19,7 +20,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class ConsentController extends AbstractController
|
||||
{
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings)
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -46,16 +47,21 @@ final class ConsentController extends AbstractController
|
||||
}
|
||||
// Deliberately without header and footer: this is a page about a decision, not a page of
|
||||
// the website, and it must work even when an area is broken.
|
||||
// Palette und Hinweisfarben wie auf den Seiten, damit die Entscheidung hier aussieht wie
|
||||
// die Frage dort. Das Stylesheet ist ueber seinen Hash erlaubt, nichts weiter.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
$response = $this->render('consent.html.twig', [
|
||||
'consent' => $this->consent->state($request),
|
||||
'settings' => $this->settings->load(),
|
||||
'pageStyles' => $styles,
|
||||
]);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
// The page carries no embeds and no own styles, so it keeps the strictest policy.
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// The page carries no embeds, so it keeps the strictest policy - plus the one hashed style block.
|
||||
$response->headers->set('Content-Security-Policy', sprintf("default-src 'self'; script-src 'none'; style-src 'self' 'sha256-%s'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", base64_encode(hash('sha256', $styles, true))));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class PageController extends AbstractController
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
@@ -308,21 +308,6 @@ final class PageController extends AbstractController
|
||||
if (!($settings['scrollTop'] ?? false)) {
|
||||
return '';
|
||||
}
|
||||
$farbe = static function (string $wert) use ($theme): ?string {
|
||||
if ('' === $wert) {
|
||||
return null;
|
||||
}
|
||||
if (!str_starts_with($wert, '@')) {
|
||||
return $wert;
|
||||
}
|
||||
foreach ($theme['colors'] as $eintrag) {
|
||||
if ($eintrag['key'] === substr($wert, 1)) {
|
||||
return sprintf('var(--k-%s)', $eintrag['key']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
|
||||
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
|
||||
'eckig' => '0',
|
||||
@@ -331,7 +316,7 @@ final class PageController extends AbstractController
|
||||
});
|
||||
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
|
||||
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
|
||||
$wert = $farbe((string) ($settings[$feld] ?? ''));
|
||||
$wert = Theme::cssColor((string) ($settings[$feld] ?? ''), $theme);
|
||||
if (null !== $wert) {
|
||||
$werte[] = $variable.':'.$wert;
|
||||
}
|
||||
@@ -514,8 +499,13 @@ final class PageController extends AbstractController
|
||||
$areas = $this->areas();
|
||||
$frames = $this->frameHosts($request, ...array_values($areas));
|
||||
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
|
||||
// Palette und Hinweisfarben wie auf jeder Seite - das Archiv trug bisher weder das eine
|
||||
// noch das andere und zeigte den Einwilligungshinweis in den Standardfarben.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
|
||||
return $this->shell($this->render('archive.html.twig', [
|
||||
'pageStyles' => $styles,
|
||||
'areas' => $areas,
|
||||
'needsScrolled' => $needsScrolled,
|
||||
'navigation' => $this->navigation(''),
|
||||
@@ -525,7 +515,7 @@ final class PageController extends AbstractController
|
||||
'base' => $base,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
|
||||
'navigation' => $this->navigation(''),
|
||||
]), $needsScrolled, frameHosts: $frames);
|
||||
]), $needsScrolled, $styles, $frames);
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
|
||||
Reference in new issue
Block a user