Umut: "Effekte gibts keine, sind die aktiv?" Nachgemessen: die Zeigereffekte liefen alle. Die Einblendungen gab es seit Schema v3 - nur benutzt hatte sie niemand, weil eine Animation beim Laden unterhalb der Falz wertlos ist. Mit animation-timeline: view() laufen dieselben Keyframes, wenn der Abschnitt in den Blick kommt - hinter der Bewegungsschranke, hinter @supports (Firefox und Safari behalten das Einblenden beim Laden) und mit einem Bereich, der endet, bevor der Abschnitt in der Mitte steht. Dabei hat axe einen Fehler gefunden, den ich selbst gebaut hatte: eine Scroll-Zeitachse ist umkehrbar, also stand alles unterhalb der Falz dauerhaft am Anfang der Kurve - bei opacity .35 blass, bei .85 immer noch 3,86:1 statt 7,1:1. Auf dem Scrollweg wird deshalb nur Bewegung animiert, keine Deckkraft. Benutzt wird es jetzt auch: alle drei Beitragsvorlagen bringen die Einblendung je Abschnitt mit. Geprueft: 400 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung, axe ohne Verstoss auf Startseite, Projekteseite und Beitrag - vor und nach dem Durchscrollen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
14 KiB
PHP
266 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Content;
|
||
|
||
/**
|
||
* Fertige Aufbauten für einen neuen Beitrag.
|
||
*
|
||
* Ein leeres Dokument ist für eine Seite richtig - dort weiß niemand vorher, was daraus wird. Ein
|
||
* Beitrag dagegen hat fast immer dieselbe Form: ein Kopf, ein Text, Bilder, und am Ende ein Weg
|
||
* weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig
|
||
* Beiträgen sieht ein Archiv aus wie zwanzig Websites.
|
||
*
|
||
* Die drei Aufbauten hier sind Startpunkte, keine Schablonen: jeder Block lässt sich danach
|
||
* verschieben, ändern und löschen wie jeder andere Block auch. Was sie festlegen, ist die erste
|
||
* Entscheidung - und die ist die, die am schwersten nachzuholen ist.
|
||
*/
|
||
final class PostLayouts
|
||
{
|
||
public const DEFAULT = 'bildkopf';
|
||
|
||
/** @return array<string, array{name: string, description: string}> */
|
||
public static function all(): array
|
||
{
|
||
return [
|
||
'bildkopf' => [
|
||
'name' => 'Bildkopf',
|
||
'description' => 'Großes Beitragsbild über die volle Breite, Titel darauf. Für Projekte und alles, was von einem Bild lebt.',
|
||
],
|
||
'klassisch' => [
|
||
'name' => 'Klassisch',
|
||
'description' => 'Titel und Vorspann in einer schmalen Spalte, Bild darunter. Ruhig zu lesen, gut für lange Texte.',
|
||
],
|
||
'magazin' => [
|
||
'name' => 'Magazin',
|
||
'description' => 'Titel neben den Eckdaten, danach Text und Galerie. Für Beiträge, bei denen Kunde, Leistung und Jahr zählen.',
|
||
],
|
||
];
|
||
}
|
||
|
||
public static function exists(string $key): bool
|
||
{
|
||
return isset(self::all()[$key]);
|
||
}
|
||
|
||
/** Das vollständige Dokument eines neuen Beitrags in der gewählten Form. */
|
||
public static function document(string $key, string $title): array
|
||
{
|
||
$document = [
|
||
'schemaVersion' => 18,
|
||
'title' => $title,
|
||
'description' => 'Ein neuer Beitrag. Ersetze diese Beschreibung durch eine kurze Zusammenfassung – sie erscheint in Suchergebnissen und auf der Beitragskarte.',
|
||
'blocks' => match ($key) {
|
||
'klassisch' => self::klassisch($title),
|
||
'magazin' => self::magazin($title),
|
||
default => self::bildkopf($title),
|
||
},
|
||
];
|
||
|
||
return $document;
|
||
}
|
||
|
||
/**
|
||
* Bildkopf: das Beitragsbild füllt den Kopf, der Titel liegt darauf.
|
||
*
|
||
* Das Bild ist beim Anlegen noch keines - es kommt ein Platzhalter, und im Designbereich wird
|
||
* daraus mit zwei Klicks das Beitragsbild. Ein dunkler Kopf ohne Bild sieht dabei nicht kaputt
|
||
* aus, sondern schlicht, und das ist der Punkt: kein Aufbau darf auf ein Bild angewiesen sein,
|
||
* das noch niemand hochgeladen hat.
|
||
*/
|
||
private static function bildkopf(string $title): array
|
||
{
|
||
return [
|
||
self::section('kopf', [
|
||
self::badge('kopf-label', 'Beitrag'),
|
||
self::heading('kopf-titel', $title, 1, ['fontSize' => '46px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px', 'maxWidth' => '900px', 'margin' => self::spacing('0px', '18px', 'auto', 'auto')]),
|
||
self::line('kopf-linie'),
|
||
], [
|
||
'backgroundColor' => '@ink', 'color' => '#ffffff', 'textAlign' => 'center',
|
||
'display' => 'flex', 'alignItems' => 'flex-end', 'justifyContent' => 'center',
|
||
'minHeight' => '58vh', 'padding' => self::spacing('110px', '52px', '0px', '0px'),
|
||
], [], ['entrance' => 'fade']),
|
||
self::narrow('text', [
|
||
self::heading('text-h', 'Über den Beitrag', 2, ['fontSize' => '28px', 'fontWeight' => '800', 'color' => '@ink']),
|
||
self::text('text-1', 'Schreibe hier den ersten Absatz. Er beantwortet am besten die Frage, warum jemand weiterlesen sollte.'),
|
||
self::text('text-2', 'Der zweite Absatz darf ins Detail gehen: was die Aufgabe war, wie der Weg aussah, was dabei herauskam.'),
|
||
]),
|
||
self::section('galerie', [
|
||
self::badge('galerie-label', 'Bildergalerie', true),
|
||
self::heading('galerie-h', 'Eindrücke', 2, ['fontSize' => '30px', 'fontWeight' => '800', 'color' => '@ink']),
|
||
self::gallery('galerie-bilder', 2),
|
||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('60px', '60px', '0px', '0px')], [], ['entrance' => 'rise']),
|
||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||
self::section('aufruf', [self::cta('aufruf-band')], ['backgroundColor' => '@ink', 'color' => '#ffffff', 'padding' => self::spacing('62px', '62px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||
];
|
||
}
|
||
|
||
/** Klassisch: Titel und Vorspann schmal, Bild darunter, dann der Text. */
|
||
private static function klassisch(string $title): array
|
||
{
|
||
return [
|
||
self::narrow('kopf', [
|
||
self::badge('kopf-label', 'Beitrag', true),
|
||
self::heading('kopf-titel', $title, 1, ['fontSize' => '42px', 'fontWeight' => '800', 'lineHeight' => 1.15, 'letterSpacing' => '-0.8px']),
|
||
self::text('kopf-vorspann', 'Ein Vorspann in zwei, drei Sätzen. Er steht für sich und sagt, worum es geht – wer nur ihn liest, weiß Bescheid.', ['fontSize' => '20px', 'lineHeight' => 1.6]),
|
||
], '70px', '30px'),
|
||
self::section('bild', [self::image('bild-1')], ['padding' => self::spacing('0px', '10px', '0px', '0px')], [], ['entrance' => 'zoom']),
|
||
self::narrow('text', [
|
||
self::text('text-1', 'Der erste Absatz des Haupttextes. Ab hier darf es ausführlich werden.'),
|
||
self::heading('text-h', 'Ein Zwischentitel', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('34px', '12px', '0px', '0px')]),
|
||
self::text('text-2', 'Zwischentitel machen einen langen Text überhaupt erst lesbar. Setze alle drei bis fünf Absätze einen.'),
|
||
]),
|
||
self::section('weiter', [self::nav('weiter-nav')], ['backgroundColor' => '@paper', 'padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||
];
|
||
}
|
||
|
||
/** Magazin: Titel links, Eckdaten rechts, danach Text und Galerie. */
|
||
private static function magazin(string $title): array
|
||
{
|
||
return [
|
||
self::section('kopf', [
|
||
['id' => 'kopf-zeile', 'type' => 'row', 'style' => ['desktop' => ['gridColumns' => 2, 'gap' => '40px', 'alignItems' => 'flex-end'], 'mobile' => ['gridColumns' => 1, 'gap' => '24px']], 'children' => [
|
||
['id' => 'kopf-links', 'type' => 'column', 'children' => [
|
||
self::badge('kopf-label', 'Beitrag', true),
|
||
self::heading('kopf-titel', $title, 1, ['fontSize' => '44px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px']),
|
||
]],
|
||
['id' => 'kopf-rechts', 'type' => 'column', 'style' => ['desktop' => [
|
||
'backgroundColor' => '#ffffff', 'borderRadius' => self::corners('16px'),
|
||
'borderWidth' => self::spacing('1px', '1px', '1px', '1px'), 'borderStyle' => 'solid', 'borderColor' => '@line',
|
||
'padding' => self::spacing('22px', '22px', '24px', '24px'),
|
||
]], 'children' => [
|
||
self::badge('eck-label', 'Eckdaten', true),
|
||
self::text('eck-daten', "Kunde: noch einzutragen\nLeistung: noch einzutragen\nJahr: noch einzutragen", ['fontSize' => '15px']),
|
||
]],
|
||
]],
|
||
], ['backgroundColor' => '@paper', 'padding' => self::spacing('72px', '48px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||
self::narrow('text', [
|
||
self::text('text-1', 'Der Einstieg in den Beitrag. Was war die Aufgabe, und warum war sie nicht trivial?'),
|
||
self::heading('text-h', 'Der Weg', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
|
||
self::text('text-2', 'Was getan wurde, in der Reihenfolge, in der es getan wurde.'),
|
||
self::heading('text-h2', 'Das Ergebnis', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
|
||
self::text('text-3', 'Was sich seitdem geändert hat – am besten ohne Superlative.'),
|
||
]),
|
||
self::section('galerie', [self::gallery('galerie-bilder', 3)], ['backgroundColor' => '@paper', 'padding' => self::spacing('56px', '56px', '0px', '0px')], [], ['entrance' => 'rise']),
|
||
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')], [], ['entrance' => 'fade']),
|
||
];
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Bausteine
|
||
|
||
/**
|
||
* Ein Abschnitt, wahlweise mit Einblendung.
|
||
*
|
||
* Die Einblendung läuft beim Hineinscrollen, wo der Browser eine Scroll-Zeitachse kennt, und
|
||
* sonst beim Laden - beides ohne Skript, beides hinter der Bewegungsschranke. Deshalb darf
|
||
* sie auch weiter unten stehen, ohne ins Leere zu laufen.
|
||
*/
|
||
private static function section(string $id, array $children, array $desktop = [], array $mobile = [], array $effects = []): array
|
||
{
|
||
$block = ['id' => $id, 'type' => 'section', 'children' => $children];
|
||
if ([] !== $effects) {
|
||
$block['layout'] = ['effects' => $effects];
|
||
}
|
||
if ([] !== $desktop || [] !== $mobile) {
|
||
$block['style'] = array_filter(['desktop' => $desktop, 'mobile' => $mobile]);
|
||
}
|
||
|
||
return $block;
|
||
}
|
||
|
||
/** Ein schmaler Abschnitt steht in der Mitte: maximale Breite ohne automatischen Rand ist eine halbe Einstellung. */
|
||
private static function narrow(string $id, array $children, string $top = '54px', string $bottom = '54px'): array
|
||
{
|
||
return self::section($id, $children, [
|
||
'maxWidth' => '820px',
|
||
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
|
||
'padding' => self::spacing($top, $bottom, '0px', '0px'),
|
||
], [], ['entrance' => 'rise']);
|
||
}
|
||
|
||
private static function heading(string $id, string $text, int $level, array $style = []): array
|
||
{
|
||
return array_filter([
|
||
'id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level],
|
||
'style' => [] === $style ? null : ['desktop' => $style],
|
||
], static fn ($value) => null !== $value);
|
||
}
|
||
|
||
private static function text(string $id, string $text, array $style = []): array
|
||
{
|
||
return array_filter([
|
||
'id' => $id, 'type' => 'text', 'props' => ['text' => $text],
|
||
'style' => [] === $style ? null : ['desktop' => $style],
|
||
], static fn ($value) => null !== $value);
|
||
}
|
||
|
||
/** Die kleine Beschriftung über einer Überschrift - hell auf dunklem Kopf, sonst in der Akzentfarbe. */
|
||
private static function badge(string $id, string $label, bool $quiet = false): array
|
||
{
|
||
return $quiet
|
||
? self::text($id, $label, ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '1.6px', 'textTransform' => 'uppercase', 'color' => '@link', 'margin' => self::spacing('0px', '10px', '0px', '0px')])
|
||
: self::text($id, $label, [
|
||
'display' => 'inline-block', 'backgroundColor' => '@link', 'color' => '#ffffff',
|
||
'borderRadius' => self::corners('999px'), 'padding' => self::spacing('7px', '7px', '16px', '16px'),
|
||
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '1px', 'textTransform' => 'uppercase',
|
||
'margin' => self::spacing('0px', '18px', 'auto', 'auto'),
|
||
]);
|
||
}
|
||
|
||
private static function line(string $id): array
|
||
{
|
||
return ['id' => $id, 'type' => 'divider', 'style' => ['desktop' => [
|
||
'width' => '60px', 'height' => '4px', 'backgroundColor' => '@link',
|
||
'borderRadius' => self::corners('2px'), 'borderWidth' => self::spacing('0px', '0px', '0px', '0px'),
|
||
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
|
||
]]];
|
||
}
|
||
|
||
private static function image(string $id): array
|
||
{
|
||
return ['id' => $id, 'type' => 'image', 'props' => [
|
||
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
|
||
'width' => 1200, 'height' => 675, 'caption' => '',
|
||
], 'style' => ['desktop' => ['imageRadius' => self::corners('18px')]]];
|
||
}
|
||
|
||
private static function gallery(string $id, int $count): array
|
||
{
|
||
$children = [];
|
||
for ($index = 1; $index <= $count; ++$index) {
|
||
$children[] = ['id' => $id.'-'.$index, 'type' => 'image', 'props' => [
|
||
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
|
||
'width' => 1200, 'height' => 900,
|
||
]];
|
||
}
|
||
|
||
return ['id' => $id, 'type' => 'gallery', 'children' => $children, 'style' => ['desktop' => [
|
||
'gridColumns' => $count, 'gap' => '18px', 'margin' => self::spacing('24px', '0px', '0px', '0px'),
|
||
], 'mobile' => ['gridColumns' => 1]]];
|
||
}
|
||
|
||
private static function nav(string $id): array
|
||
{
|
||
return ['id' => $id, 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriger Beitrag', 'nextLabel' => 'Nächster Beitrag']];
|
||
}
|
||
|
||
private static function cta(string $id): array
|
||
{
|
||
return ['id' => $id, 'type' => 'cta', 'props' => [
|
||
'title' => 'Noch Fragen?',
|
||
'text' => 'Schreib uns – wir antworten in der Regel am selben Werktag.',
|
||
'buttonText' => 'Kontakt aufnehmen',
|
||
'href' => '/#kontakt',
|
||
]];
|
||
}
|
||
|
||
private static function spacing(string $top, string $bottom, string $left, string $right): array
|
||
{
|
||
return ['top' => $top, 'bottom' => $bottom, 'left' => $left, 'right' => $right];
|
||
}
|
||
|
||
private static function corners(string $value): array
|
||
{
|
||
return ['topLeft' => $value, 'topRight' => $value, 'bottomRight' => $value, 'bottomLeft' => $value];
|
||
}
|
||
}
|