Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.90% covered (success)
99.90%
1044 / 1045
95.00% covered (success)
95.00%
19 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Styleguide
99.90% covered (success)
99.90%
1044 / 1045
95.00% covered (success)
95.00%
19 / 20
40
0.00% covered (danger)
0.00%
0 / 1
 index
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 tokenGroups
98.04% covered (success)
98.04%
50 / 51
0.00% covered (danger)
0.00%
0 / 1
17
 fields
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
1
 alignmentFields
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
1
 fieldset
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 richtextFields
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
1
 mediaFields
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
1
 entriesFields
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
1
 entriesContent
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
2
 blocksFields
100.00% covered (success)
100.00%
215 / 215
100.00% covered (success)
100.00%
1 / 1
1
 blocksContent
100.00% covered (success)
100.00%
142 / 142
100.00% covered (success)
100.00%
1 / 1
2
 mediaContent
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
2
 galleryUid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mediaAssets
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
2
 richtextContent
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 codeFields
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 codeContent
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 inspector
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 rows
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
1 / 1
1
 content
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Cosray\Block as Builtin;
8use Cosray\Field\Blocks;
9use Cosray\Field\Control;
10use Cosray\Field\Control\Registry as Controls;
11use Cosray\Field\Iframe;
12use Cosray\Field\Image;
13use Cosray\Field\Option;
14use Cosray\Field\RichText;
15use Cosray\Field\Text;
16use Cosray\Field\Textarea;
17use Cosray\Field\Video;
18use Cosray\Field\Youtube;
19use Cosray\Locales;
20use Cosray\Panel\System;
21use Cosray\Richtext\Envelope;
22use Cosray\Schema\Tool;
23
24/**
25 * Renders every panel component against the current stylesheets, so rare
26 * states (empty, error, disabled, truncating, both themes) can be checked
27 * without hunting for content that happens to produce them.
28 *
29 * Registered only when `app.debug` is on.
30 */
31final class Styleguide extends Panel
32{
33    protected const string AREA = 'styleguide';
34    private const int GALLERY_SIZE = 14;
35
36    /** @return array<string, mixed> */
37    public function index(Controls $controls): array
38    {
39        $locales = $this->container->instance(Locales::class);
40
41        return $this->context([
42            // Addressable state: one section, one theme, one screenshot.
43            // `section` matches the data-section key a section carries.
44            'section' => (string) $this->request->param('section', ''),
45            'theme' => (string) $this->request->param('theme', ''),
46            'tokenGroups' => $this->tokenGroups(),
47            'locales' => [
48                ['id' => 'en', 'title' => 'English'],
49                ['id' => 'de', 'title' => 'Deutsch', 'fallback' => 'en'],
50            ],
51            'defaultLocale' => 'en',
52            'fields' => $this->fields(),
53            'alignmentFields' => $this->alignmentFields(),
54            'fieldset' => $this->fieldset(),
55            'content' => $this->content(),
56            'rows' => $this->rows(),
57            'inspector' => $this->inspector(),
58            'richtextFields' => $this->richtextFields($controls),
59            'richtextContent' => $this->richtextContent(),
60            'codeFields' => $this->codeFields($controls),
61            'codeContent' => $this->codeContent(),
62            'mediaFields' => $this->mediaFields($controls),
63            'mediaContent' => $this->mediaContent(),
64            'mediaAssets' => $this->mediaAssets(),
65            'entriesFields' => $this->entriesFields($controls),
66            'entriesContent' => $this->entriesContent(),
67            'blocksFields' => $this->blocksFields($controls),
68            'blocksContent' => $this->blocksContent(),
69            // Media controls need the editor bridge for uploads and the
70            // library picker; the payload is the one the editor embeds.
71            'system' => new System($this->config, $locales, $this->csrfToken())->payload(),
72        ]);
73    }
74
75    /**
76     * Token groups read out of `tokens.css` rather than listed here, so the
77     * page cannot drift from the stylesheet it documents. Only the `:root`
78     * block is read â€” the rules after it force a theme, they declare no
79     * tokens.
80     *
81     * @return list<array{title: string, open: bool, tokens: list<array{name: string, value: string, swatch: bool}>}>
82     */
83    private function tokenGroups(): array
84    {
85        $path = $this->panelDir . '/styles/tokens.css';
86
87        if (!is_file($path)) {
88            return [];
89        }
90
91        $groups = [];
92        $title = 'Tokens';
93        $declaration = '';
94        $comment = [];
95        $inRoot = false;
96        $inComment = false;
97        $inNote = false;
98
99        foreach (preg_split('/\R/', (string) file_get_contents($path)) ?: [] as $line) {
100            $line = trim($line);
101
102            if (!$inRoot) {
103                $inRoot = $line === ':root {';
104
105                continue;
106            }
107
108            if ($line === '}') {
109                break;
110            }
111
112            // A `/**` block names a group; a plain `/*` block is a note about
113            // the token below it and carries no heading.
114            if ($inNote || str_starts_with($line, '/*') && !str_starts_with($line, '/**')) {
115                $inNote = !str_ends_with($line, '*/');
116
117                continue;
118            }
119
120            if ($inComment || str_starts_with($line, '/**')) {
121                $inComment = !str_ends_with($line, '*/');
122                $text = trim(trim($line, '/*'));
123
124                if ($text !== '' && $comment === []) {
125                    $comment[] = $text;
126                }
127
128                if (!$inComment) {
129                    $title = $comment[0] ?? $title;
130                    $comment = [];
131                }
132
133                continue;
134            }
135
136            // A declaration may span several lines (multi-line color-mix()).
137            $declaration = trim($declaration . ' ' . $line);
138
139            if (!str_ends_with($declaration, ';') || !str_starts_with($declaration, '--cms-')) {
140                if (str_ends_with($declaration, ';')) {
141                    $declaration = '';
142                }
143
144                continue;
145            }
146
147            [$name, $value] = explode(':', $declaration, 2);
148            $name = trim($name);
149            $declaration = '';
150
151            $groups[$title][] = [
152                'name' => $name,
153                'value' => trim(rtrim(trim($value), ';')),
154                'swatch' => str_contains($name, 'color'),
155            ];
156        }
157
158        // Primitives are the longest and least consulted group â€” 55 spacing
159        // steps ahead of everything worth looking up â€” so they start collapsed.
160        return array_map(
161            static fn(string $title, array $tokens): array => [
162                'title' => $title,
163                'open' => !str_starts_with($title, 'Primitives'),
164                'tokens' => $tokens,
165            ],
166            array_keys($groups),
167            array_values($groups),
168        );
169    }
170
171    /**
172     * Field descriptors in the shape the editor passes to `field/item`.
173     *
174     * @return list<array<string, mixed>>
175     */
176    private function fields(): array
177    {
178        return [
179            [
180                'name' => 'title',
181                'label' => 'Title',
182                'control' => ['name' => 'text', 'props' => ['placeholder' => 'Untitled']],
183                'required' => true,
184                'description' => 'Shown in listings, search results and when the page is shared.',
185            ],
186            [
187                'name' => 'teaser',
188                'label' => 'Teaser',
189                'control' => ['name' => 'textarea', 'props' => []],
190                'translate' => true,
191            ],
192            [
193                'name' => 'category',
194                'label' => 'Category',
195                'control' => ['name' => 'option', 'props' => []],
196                'options' => ['news', 'event', 'press'],
197            ],
198            [
199                'name' => 'weight',
200                'label' => 'Weight',
201                'control' => ['name' => 'number', 'props' => []],
202                'width' => 50,
203            ],
204            [
205                'name' => 'featured',
206                'label' => 'Featured',
207                'control' => ['name' => 'checkbox', 'props' => []],
208                'width' => 50,
209            ],
210            [
211                'name' => 'accent',
212                'label' => 'Accent color',
213                'control' => ['name' => 'color', 'props' => []],
214                'width' => 50,
215            ],
216            [
217                'name' => 'background',
218                'label' => 'Background color',
219                'control' => ['name' => 'color', 'props' => []],
220                'width' => 50,
221            ],
222            [
223                'name' => 'locked',
224                'label' => 'Locked',
225                'control' => ['name' => 'text', 'props' => []],
226                'immutable' => true,
227                'description' => 'Immutable fields render read-only.',
228            ],
229            [
230                'name' => 'invalid',
231                'label' => 'Invalid',
232                'control' => ['name' => 'text', 'props' => []],
233                'required' => true,
234                'description' => 'Marked invalid the way the save path marks a field that failed.',
235            ],
236            [
237                'name' => 'overflow',
238                'label' => 'A label long enough to find out what happens when it does not fit',
239                'control' => ['name' => 'text', 'props' => []],
240            ],
241        ];
242    }
243
244    /** @return list<array<string, mixed>> */
245    private function alignmentFields(): array
246    {
247        return [
248            [
249                'name' => 'alignmentShort',
250                'label' => 'Short label',
251                'control' => Control::text()->array(),
252                'width' => 25,
253                'required' => true,
254                'description' => 'Help stays next to this input, even beside a taller control.',
255            ],
256            [
257                'name' => 'alignmentLong',
258                'label' => 'A longer label that wraps across multiple lines',
259                'control' => Control::text()->array(),
260                'width' => 25,
261            ],
262            [
263                'name' => 'alignmentDate',
264                'label' => 'Date',
265                'control' => Control::date()->array(),
266                'width' => 25,
267            ],
268            [
269                'name' => 'alignmentTall',
270                'label' => 'Taller control',
271                'control' => Control::textarea()->array(),
272                'width' => 25,
273            ],
274            [
275                'name' => 'alignmentSpan',
276                'label' => 'Spans two rows',
277                'control' => Control::textarea()->array(),
278                'width' => 50,
279                'rowspan' => 2,
280                'description' => 'The control keeps its own height; the next two fields sit beside it.',
281            ],
282            [
283                'name' => 'alignmentNext',
284                'label' => 'Beside the spanning field',
285                'control' => Control::text()->array(),
286                'width' => 50,
287            ],
288            [
289                'name' => 'alignmentLast',
290                'label' => 'Second row beside the spanning field',
291                'control' => Control::text()->array(),
292                'width' => 50,
293            ],
294            [
295                'name' => 'alignmentToggle',
296                'label' => 'Show conditional field',
297                'control' => Control::checkbox()->array(),
298                'width' => 25,
299            ],
300            [
301                'name' => 'alignmentConditional',
302                'label' => 'A conditional field with a longer label',
303                'control' => Control::text()->array(),
304                'width' => 25,
305                'required' => true,
306                'when' => ['field' => 'alignmentToggle', 'op' => 'truthy', 'value' => null],
307            ],
308            [
309                'name' => 'alignmentAlways',
310                'label' => 'Always visible',
311                'control' => Control::text()->array(),
312                'width' => 50,
313            ],
314        ];
315    }
316
317    /**
318     * A fieldset descriptor in the shape the editor passes to `field/fieldset`;
319     * its members come out of `fields()`, the rest render as a loose run below
320     * it â€” the two section forms the editor sheet knows.
321     *
322     * @return array<string, mixed>
323     */
324    private function fieldset(): array
325    {
326        return [
327            'name' => 'basics',
328            'label' => 'Basics',
329            'description' => 'Title and teaser are shown in listings, search results and when the page is shared.',
330            'fields' => ['title', 'teaser'],
331        ];
332    }
333
334    /**
335     * Two richtext descriptors: the built-in default toolbar and a field
336     * trimmed the way `#[Tools]` would.
337     *
338     * @return list<array<string, mixed>>
339     */
340    private function richtextFields(Controls $controls): array
341    {
342        $control = Control::richtext()->resolve($controls)->array();
343
344        return [
345            [
346                'name' => 'rtDefault',
347                'label' => 'Richtext â€” default tools',
348                'control' => $control,
349                'tools' => array_map(static fn(Tool $tool): string => $tool->value, Tool::DEFAULT),
350                'richtextClasses' => (object) [],
351                'richtextStyles' => (object) [],
352            ],
353            [
354                'name' => 'rtTrimmed',
355                'label' => 'Richtext â€” #[Tools(Bold, Italic, Link, Source)]',
356                'control' => $control,
357                'tools' => ['bold', 'italic', 'link', 'source'],
358                'richtextClasses' => (object) [],
359                'richtextStyles' => (object) [],
360            ],
361            // The state an element control cannot be checked in anywhere else:
362            // the document without its toolbar, refusing input.
363            [
364                'name' => 'rtReadonly',
365                'label' => 'Richtext â€” read-only',
366                'control' => $control,
367                'immutable' => true,
368                'tools' => array_map(static fn(Tool $tool): string => $tool->value, Tool::DEFAULT),
369                'richtextClasses' => (object) [],
370                'richtextStyles' => (object) [],
371            ],
372        ];
373    }
374
375    /**
376     * Media descriptors in every shape the controls take: a single image
377     * and a gallery, a single file and a list, a video â€” filled, empty
378     * and read-only.
379     *
380     * @return list<array<string, mixed>>
381     */
382    private function mediaFields(Controls $controls): array
383    {
384        $control = Control::image()->resolve($controls)->array();
385        $file = Control::file()->resolve($controls)->array();
386        $video = Control::video()->resolve($controls)->array();
387        $single = ['min' => 0, 'max' => 1];
388        $many = ['min' => 0, 'max' => -1];
389
390        return [
391            [
392                'name' => 'cover',
393                'label' => 'Cover image',
394                'control' => $control,
395                'limit' => $single,
396                'translate' => true,
397                'description' => 'Alt text and caption are edited in place; the thumbnail opens the preview.',
398            ],
399            [
400                'name' => 'coverEmpty',
401                'label' => 'Cover image â€” empty, required',
402                'control' => $control,
403                'limit' => $single,
404                'required' => true,
405            ],
406            [
407                'name' => 'gallery',
408                'label' => 'Gallery',
409                'control' => $control,
410                'limit' => $many,
411                'description' => 'Selecting a tile opens the drawer; tiles reorder by drag.',
412            ],
413            [
414                'name' => 'galleryEmpty',
415                'label' => 'Gallery â€” empty',
416                'control' => $control,
417                'limit' => $many,
418            ],
419            // The read-only state: the picture and its per-use texts stay,
420            // everything that would replace or drop the file is gone.
421            [
422                'name' => 'coverReadonly',
423                'label' => 'Cover image â€” read-only',
424                'control' => $control,
425                'limit' => $single,
426                'immutable' => true,
427            ],
428            [
429                'name' => 'downloads',
430                'label' => 'Downloads',
431                'control' => $file,
432                'limit' => ['min' => 0, 'max' => 5],
433                'description' => 'Rows reorder by drag; the pencil edits the title.',
434            ],
435            [
436                'name' => 'brochure',
437                'label' => 'Brochure',
438                'control' => $file,
439                'limit' => $single,
440            ],
441            [
442                'name' => 'brochureEmpty',
443                'label' => 'Brochure â€” empty',
444                'control' => $file,
445                'limit' => $single,
446            ],
447            [
448                'name' => 'downloadsReadonly',
449                'label' => 'Downloads â€” read-only',
450                'control' => $file,
451                'limit' => $many,
452                'immutable' => true,
453            ],
454            [
455                'name' => 'clip',
456                'label' => 'Clip',
457                'control' => $video,
458                'limit' => $single,
459            ],
460            [
461                'name' => 'clipEmpty',
462                'label' => 'Clip â€” empty',
463                'control' => $video,
464                'limit' => $single,
465            ],
466        ];
467    }
468
469    /**
470     * Entries descriptors: a field allowing two entry types â€” one with an
471     * image, so rows carry a thumb â€” and a single-type field with no rows.
472     *
473     * @return list<array<string, mixed>>
474     */
475    private function entriesFields(Controls $controls): array
476    {
477        $image = Control::image()->resolve($controls)->array();
478        $text = static fn(string $name, string $label, int $width = 100): array => [
479            'name' => $name,
480            'label' => $label,
481            'type' => Text::class,
482            'control' => ['name' => 'text', 'props' => []],
483            'width' => $width,
484        ];
485        $person = [
486            'type' => 'App\\Styleguide\\Person',
487            'label' => 'Person',
488            'fields' => [
489                [
490                    'name' => 'photo',
491                    'label' => 'Photo',
492                    'type' => Image::class,
493                    'control' => $image,
494                    'limit' => ['min' => 0, 'max' => 1],
495                    'width' => 34,
496                ],
497                $text('name', 'Name', 66) + ['required' => true],
498                $text('role', 'Role', 66),
499            ],
500            'fieldsets' => [],
501        ];
502        $quote = [
503            'type' => 'App\\Styleguide\\Quote',
504            'label' => 'Quote',
505            'fields' => [
506                [
507                    'name' => 'text',
508                    'label' => 'Quote',
509                    'type' => Textarea::class,
510                    'control' => ['name' => 'textarea', 'props' => []],
511                ],
512                $text('author', 'Author'),
513            ],
514            'fieldsets' => [],
515        ];
516
517        return [
518            [
519                'name' => 'team',
520                'label' => 'Team',
521                'control' => [
522                    'name' => 'entries',
523                    'props' => ['entryTypes' => [$person, $quote], 'min' => 0],
524                ],
525                'description' => 'Rows collapse to a summary; the summary follows the form while typing.',
526            ],
527            [
528                'name' => 'teamEmpty',
529                'label' => 'Team â€” empty, one type',
530                'control' => [
531                    'name' => 'entries',
532                    'props' => ['entryTypes' => [$person], 'min' => 0, 'max' => 3],
533                ],
534            ],
535            // A read-only repeater: rows readable, nothing that restructures
536            // them â€” no adders, grip, row menu or sub-field input.
537            [
538                'name' => 'teamReadonly',
539                'label' => 'Team â€” read-only',
540                'control' => [
541                    'name' => 'entries',
542                    'props' => ['entryTypes' => [$person], 'min' => 0],
543                ],
544                'immutable' => true,
545            ],
546        ];
547    }
548
549    /**
550     * @return array<string, array<string, mixed>>
551     */
552    private function entriesContent(): array
553    {
554        $person = static fn(string $uid, ?string $photo, string $name, string $role): array => [
555            'uid' => $uid,
556            'type' => 'App\\Styleguide\\Person',
557            'fields' => [
558                'photo' => ['value' => ['zxx' => $photo === null ? [] : [['uid' => $photo]]]],
559                'name' => ['value' => ['zxx' => $name]],
560                'role' => ['value' => ['zxx' => $role]],
561            ],
562        ];
563
564        return [
565            'team' => [
566                'value' => [
567                    'zxx' => [
568                        $person('sg-person-1', 'sg-cover', 'Anja Reinhardt', 'Head brewer'),
569                        $person('sg-person-2', null, 'Sofia Mendes', ''),
570                        [
571                            'uid' => 'sg-quote-1',
572                            'type' => 'App\\Styleguide\\Quote',
573                            'fields' => [
574                                'text' => ['value' => ['zxx' => str_repeat(
575                                    'A quote long enough to be cut short in the summary line. ',
576                                    3,
577                                )]],
578                                'author' => ['value' => ['zxx' => 'Anonymous']],
579                            ],
580                        ],
581                    ],
582                ],
583            ],
584            'teamEmpty' => ['value' => ['zxx' => []]],
585            'teamReadonly' => [
586                'value' => [
587                    'zxx' => [$person('sg-person-3', 'sg-cover', 'Imported person', 'From the supplier feed')],
588                ],
589            ],
590        ];
591    }
592
593    /**
594     * Blocks descriptors: a one-column symmetric field (a quiet list whose
595     * richtext and heading translate inside the row) and a twelve-column
596     * asymmetric field (bordered cells, one list per locale) in the shape
597     * `Field\Blocks::control()` emits.
598     *
599     * @return list<array<string, mixed>>
600     */
601    private function blocksFields(Controls $controls): array
602    {
603        $richtext = Control::richtext()->resolve($controls)->array();
604        $image = Control::image()->resolve($controls)->array();
605        $video = Control::video()->resolve($controls)->array();
606        $meta = Control::group([
607            ['key' => 'class', 'label' => 'CSS class', 'control' => Control::text()],
608            ['key' => 'id', 'label' => 'Element ID', 'control' => Control::text()],
609            ['key' => 'padding', 'label' => 'Padding', 'control' => Blocks::spacingControl()],
610        ])->array();
611        // Mirrors Blocks::metaControl(): the gap, split by axis behind a toggle.
612        $spacing = Control::group([
613            ['key' => 'gap', 'label' => 'Gap', 'control' => Blocks::spacingControl()],
614            ['key' => 'rowGap', 'label' => 'Row gap', 'control' => Blocks::spacingControl()],
615            ['key' => 'columnGap', 'label' => 'Column gap', 'control' => Blocks::spacingControl()],
616        ])->array();
617        $aspect = Control::group([
618            [
619                'key' => 'aspectRatioX',
620                'label' => 'Aspect ratio width',
621                'control' => Control::number(step: 1, min: 1),
622                'width' => 50,
623            ],
624            [
625                'key' => 'aspectRatioY',
626                'label' => 'Aspect ratio height',
627                'control' => Control::number(step: 1, min: 1),
628                'width' => 50,
629            ],
630        ])->array();
631        $build = static fn(bool $translate): array => [
632            [
633                'type' => Builtin\RichText::class,
634                'handle' => 'richtext',
635                'label' => 'Text',
636                // Mirrors Blocks::blockTypeProperties(): one visible field, no label.
637                'labels' => false,
638                'fields' => [
639                    [
640                        'name' => 'text',
641                        'label' => 'Text',
642                        'type' => RichText::class,
643                        'control' => $richtext,
644                        'translate' => $translate,
645                        'tools' => array_map(static fn(Tool $tool): string => $tool->value, Tool::INLINE),
646                        'richtextClasses' => (object) [],
647                        'richtextStyles' => (object) [],
648                    ],
649                ],
650                'fieldsets' => [],
651            ],
652            [
653                'type' => Builtin\Heading::class,
654                'handle' => 'heading',
655                'label' => 'Heading',
656                // Two visible fields, so the labels stay.
657                'labels' => true,
658                'fields' => [
659                    [
660                        'name' => 'text',
661                        'label' => 'Heading text',
662                        'type' => Text::class,
663                        'control' => ['name' => 'text', 'props' => []],
664                        'translate' => $translate,
665                        'width' => 75,
666                    ],
667                    [
668                        'name' => 'level',
669                        'label' => 'Level',
670                        'type' => Option::class,
671                        'control' => ['name' => 'option', 'props' => []],
672                        'options' => ['1', '2', '3', '4', '5', '6'],
673                        'width' => 25,
674                    ],
675                ],
676                'fieldsets' => [],
677            ],
678            [
679                'type' => Builtin\Youtube::class,
680                'handle' => 'youtube',
681                'label' => 'YouTube video',
682                'labels' => false,
683                'fields' => [
684                    [
685                        'name' => 'video',
686                        'label' => 'YouTube video',
687                        'type' => Youtube::class,
688                        'control' => ['name' => 'youtube', 'props' => []],
689                        'placeholder' => 'Paste a YouTube URL or video ID…',
690                        'metaControl' => $aspect,
691                    ],
692                ],
693                'fieldsets' => [],
694            ],
695            [
696                'type' => Builtin\Iframe::class,
697                'handle' => 'iframe',
698                'label' => 'Iframe',
699                'labels' => false,
700                'fields' => [
701                    [
702                        'name' => 'code',
703                        'label' => 'Iframe',
704                        'type' => Iframe::class,
705                        'control' => ['name' => 'iframe', 'props' => []],
706                        'placeholder' => 'Paste the embed code',
707                    ],
708                ],
709                'fieldsets' => [],
710            ],
711            [
712                'type' => Builtin\Images::class,
713                'handle' => 'images',
714                'label' => __('block:images'),
715                'labels' => false,
716                'fields' => [[
717                    'name' => 'images',
718                    'label' => __('block:images'),
719                    'control' => $image,
720                    'translate' => $translate,
721                ]],
722            ],
723            [
724                'type' => Builtin\Video::class,
725                'handle' => 'video',
726                'label' => __('block:video'),
727                'labels' => false,
728                'fields' => [[
729                    'name' => 'video',
730                    'label' => __('block:video'),
731                    'control' => $video,
732                    'translate' => $translate,
733                ]],
734            ],
735            [
736                'type' => Builtin\Image::class,
737                'handle' => 'image',
738                'label' => 'Image',
739                // Mirrors Blocks::blockTypeProperties(): one visible field, no label.
740                'labels' => false,
741                'fields' => [
742                    [
743                        'name' => 'image',
744                        'label' => 'Image',
745                        'type' => Image::class,
746                        'control' => $image,
747                        'limit' => ['min' => 0, 'max' => 1],
748                        'translate' => $translate,
749                    ],
750                ],
751                'fieldsets' => [],
752            ],
753        ];
754        // Mirrors Blocks::blockTypeProperties(): every sub-field renders inside a block.
755        $types = static fn(bool $translate): array => array_map(
756            static fn(array $type): array => [
757                ...$type,
758                'fields' => array_map(
759                    static fn(array $field): array => [...$field, 'presentation' => 'block'],
760                    $type['fields'],
761                ),
762            ],
763            $build($translate),
764        );
765
766        return [
767            [
768                'name' => 'story',
769                'label' => 'Story â€” one column, translated in the row',
770                'metaControl' => $spacing,
771                'control' => [
772                    'name' => 'blocks',
773                    'props' => [
774                        'blockTypes' => $types(true),
775                        'commonTypes' => [Builtin\RichText::class, Builtin\Image::class],
776                        'columns' => 1,
777                        'min' => 1,
778                        'responsive' => 'stack',
779                        'meta' => $meta,
780                    ],
781                ],
782                'translate' => true,
783                'translateMode' => 'symmetric',
784                'description' => 'Explicit common choices: Text and Image. More blocks opens the complete searchable catalog.',
785            ],
786            [
787                'name' => 'grid',
788                'label' => 'Grid â€” twelve columns, one list per locale',
789                'metaControl' => $spacing,
790                'control' => [
791                    'name' => 'blocks',
792                    'props' => [
793                        'blockTypes' => $types(false),
794                        'columns' => 12,
795                        'min' => 2,
796                        'responsive' => 'stack',
797                        'meta' => $meta,
798                    ],
799                ],
800                'translate' => true,
801                'translateMode' => 'asymmetric',
802                'description' => 'Default first-five menu and all seven types in the catalog. Drag an edge to resize; the gear holds layout settings.',
803            ],
804            [
805                'name' => 'singleBlock',
806                'label' => 'One type â€” direct insertion',
807                'control' => Control::blocks()
808                    ->prop('blockTypes', [$types(false)[1]])
809                    ->array(),
810            ],
811            [
812                'name' => 'fewBlocks',
813                'label' => 'Small catalog â€” no redundant More action',
814                'control' => Control::blocks()
815                    ->prop('blockTypes', array_slice($types(false), 0, 3))
816                    ->array(),
817            ],
818            [
819                'name' => 'noBlocks',
820                'label' => 'No allowed types â€” insertion unavailable',
821                'control' => Control::blocks()->prop('blockTypes', [])->array(),
822            ],
823        ];
824    }
825
826    /**
827     * @return array<string, array<string, mixed>>
828     */
829    private function blocksContent(): array
830    {
831        $doc = static fn(string $text): array => [
832            'type' => 'doc',
833            'content' => [['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]]],
834        ];
835        $layout = static fn(int $colspan, int $rowspan = 1, int $col = 0, int $row = 0): array => [
836            'colspan' => $colspan,
837            'rowspan' => $rowspan,
838            ...($col > 0 ? ['col' => $col, 'row' => $row] : []),
839        ];
840        $richtext = static fn(string $uid, array $layout, array $value): array => [
841            'uid' => $uid,
842            'type' => Builtin\RichText::class,
843            'layout' => $layout,
844            'fields' => [
845                'text' => [
846                    'type' => RichText::class,
847                    'format' => Envelope::FORMAT,
848                    'version' => Envelope::VERSION,
849                    'value' => $value,
850                ],
851            ],
852        ];
853        $heading = static fn(string $uid, array $layout, array $text, string $level): array => [
854            'uid' => $uid,
855            'type' => Builtin\Heading::class,
856            'layout' => $layout,
857            'fields' => [
858                'text' => ['type' => Text::class, 'value' => $text],
859                'level' => ['type' => Option::class, 'value' => ['zxx' => $level]],
860            ],
861        ];
862        $image = static fn(string $uid, array $layout, string $asset): array => [
863            'uid' => $uid,
864            'type' => Builtin\Image::class,
865            'layout' => $layout,
866            'fields' => ['image' => ['type' => Image::class, 'value' => ['zxx' => [['uid' => $asset]]]]],
867        ];
868        $youtube = static fn(string $uid, array $layout, string $id): array => [
869            'uid' => $uid,
870            'type' => Builtin\Youtube::class,
871            'layout' => $layout,
872            'fields' => [
873                'video' => [
874                    'type' => Youtube::class,
875                    'value' => ['zxx' => $id],
876                    'meta' => ['aspectRatioX' => ['zxx' => 16], 'aspectRatioY' => ['zxx' => 9]],
877                ],
878            ],
879        ];
880        $iframe = static fn(string $uid, array $layout, string $code): array => [
881            'uid' => $uid,
882            'type' => Builtin\Iframe::class,
883            'layout' => $layout,
884            'fields' => ['code' => ['type' => Iframe::class, 'value' => ['zxx' => $code]]],
885        ];
886        $video = static fn(string $uid, array $layout, string $asset): array => [
887            'uid' => $uid,
888            'type' => Builtin\Video::class,
889            'layout' => $layout,
890            'fields' => ['video' => ['type' => Video::class, 'value' => ['zxx' => [['uid' => $asset]]]]],
891        ];
892        $split = static fn(string $uid, array $layout, array ...$blocks): array => [
893            'uid' => $uid,
894            'layout' => $layout,
895            'blocks' => $blocks,
896        ];
897        $images = static fn(string $uid, array $layout, array $assets, array $meta): array => [
898            'uid' => $uid,
899            'type' => Builtin\Images::class,
900            'layout' => $layout,
901            'fields' => [
902                'images' => [
903                    'type' => Image::class,
904                    'value' => ['zxx' => array_map(static fn(string $asset): array => ['uid' => $asset], $assets)],
905                    'meta' => $meta,
906                ],
907            ],
908        ];
909
910        return [
911            'story' => [
912                'value' => [
913                    'zxx' => [
914                        $heading('sg-story-1', $layout(1), ['en' => 'The brewhouse', 'de' => 'Das Sudhaus'], '2'),
915                        $richtext('sg-story-2', $layout(1), [
916                            'en' => $doc('The new mash tun arrives in autumn.'),
917                            'de' => $doc('Die neue Maischepfanne wird im Herbst eingebaut.'),
918                        ]),
919                        $richtext('sg-story-3', $layout(1), [
920                            'en' => $doc('Opening hours: Tuesday to Saturday, 10 to 18.'),
921                            'de' => $doc('Öffnungszeiten: Dienstag bis Samstag, 10 bis 18 Uhr.'),
922                        ]),
923                        $image('sg-story-4', $layout(1), 'sg-cover'),
924                        $images('sg-story-5', $layout(1), array_map(self::galleryUid(...), range(1, 6)), ['ratio' => [
925                            'zxx' => '3/2',
926                        ]]),
927                    ],
928                ],
929            ],
930            'grid' => [
931                'value' => [
932                    'en' => [
933                        $image('sg-grid-1', $layout(4, 2, 1, 1), self::galleryUid(1)),
934                        $richtext('sg-grid-2', $layout(8, 1, 5, 1), [
935                            'zxx' => $doc('Eight columns beside a two-row image.'),
936                        ]),
937                        $richtext('sg-grid-3', $layout(8, 1, 5, 2), [
938                            'zxx' => $doc('The second row of the same pair.'),
939                        ]),
940                        $heading('sg-grid-4', $layout(6, 1, 4, 3), ['zxx' => 'Centered on the grid'], '3'),
941                        $richtext('sg-grid-5', $layout(4, 1, 1, 4), ['zxx' => $doc('A third.')]),
942                        $richtext('sg-grid-6', $layout(4, 1, 5, 4), ['zxx' => $doc('Another third.')]),
943                        $richtext('sg-grid-7', $layout(4, 1, 9, 4), ['zxx' => $doc('And the last third.')]),
944                        $youtube('sg-grid-8', $layout(6, 1, 1, 5), 'dQw4w9WgXcQ'),
945                        $video('sg-grid-10', $layout(6, 1, 7, 5), 'sg-clip'),
946                        $iframe(
947                            'sg-grid-9',
948                            $layout(6, 1, 1, 6),
949                            '<iframe src="https://example.org/embed" title="Map"></iframe>',
950                        ),
951                        $split(
952                            'sg-grid-11',
953                            $layout(6, 2, 7, 6),
954                            $image('sg-grid-12', $layout(3, 2), self::galleryUid(2)),
955                            $richtext('sg-grid-13', $layout(3, 2), [
956                                'zxx' => $doc('Split into columns beside its image.'),
957                            ]),
958                        ),
959                        $split(
960                            'sg-grid-14',
961                            $layout(6, 2, 1, 7),
962                            $richtext('sg-grid-15', $layout(6), ['zxx' => $doc('Split into rows: the upper one.')]),
963                            $richtext('sg-grid-16', $layout(6), ['zxx' => $doc('And the lower one.')]),
964                        ),
965                    ],
966                    'de' => [
967                        $heading('sg-grid-8', $layout(12, 1, 1, 1), ['zxx' => 'Die deutsche Liste'], '2'),
968                        $richtext('sg-grid-9', $layout(6, 1, 1, 2), ['zxx' => $doc('Eine eigene Liste je Sprache.')]),
969                    ],
970                ],
971                'meta' => [],
972            ],
973        ];
974    }
975
976    /**
977     * @return array<string, array<string, mixed>>
978     */
979    private function mediaContent(): array
980    {
981        $gallery = [];
982
983        for ($i = 1; $i <= self::GALLERY_SIZE; $i++) {
984            $gallery[] = ['uid' => self::galleryUid($i)];
985        }
986
987        $gallery[2]['meta'] = ['alt' => ['zxx' => 'Bottling line at full speed']];
988
989        return [
990            'cover' => [
991                'value' => [
992                    'zxx' => [[
993                        'uid' => 'sg-cover',
994                        'meta' => ['alt' => [
995                            'en' => 'Copper kettles in the brewhouse',
996                            'de' => 'Kupferkessel im Sudhaus',
997                        ]],
998                    ]],
999                ],
1000            ],
1001            'coverEmpty' => ['value' => ['zxx' => []]],
1002            'coverReadonly' => [
1003                'value' => [
1004                    'zxx' => [[
1005                        'uid' => 'sg-cover',
1006                        'meta' => ['caption' => ['zxx' => 'Provided by the image supplier']],
1007                    ]],
1008                ],
1009            ],
1010            'gallery' => ['value' => ['zxx' => $gallery]],
1011            'galleryEmpty' => ['value' => ['zxx' => []]],
1012            'downloads' => [
1013                'value' => [
1014                    'zxx' => [
1015                        ['uid' => 'sg-brochure', 'meta' => ['title' => ['zxx' => 'Brewery brochure']]],
1016                        ['uid' => 'sg-terms'],
1017                        ['uid' => 'sg-cover'],
1018                    ],
1019                ],
1020            ],
1021            'brochure' => ['value' => ['zxx' => [['uid' => 'sg-brochure']]]],
1022            'brochureEmpty' => ['value' => ['zxx' => []]],
1023            'downloadsReadonly' => [
1024                'value' => ['zxx' => [['uid' => 'sg-brochure'], ['uid' => 'sg-terms']]],
1025            ],
1026            'clip' => ['value' => ['zxx' => [['uid' => 'sg-clip']]]],
1027            'clipEmpty' => ['value' => ['zxx' => []]],
1028        ];
1029    }
1030
1031    private static function galleryUid(int $i): string
1032    {
1033        return sprintf('sg-gallery-%02d', $i);
1034    }
1035
1036    /**
1037     * Catalog rows for the fixture uids. The thumbnails are inline SVG
1038     * plates in shifting hues, so the samples need no files on disk.
1039     *
1040     * @return array<string, array<string, mixed>>
1041     */
1042    private function mediaAssets(): array
1043    {
1044        $plate = static function (int $hue, string $filename, int $width, int $height, int $bytes): array {
1045            $svg = sprintf(
1046                '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3">'
1047                    . '<rect width="4" height="3" fill="hsl(%d, 35%%, 62%%)"/></svg>',
1048                $hue,
1049            );
1050            $url = 'data:image/svg+xml,' . rawurlencode($svg);
1051
1052            return [
1053                'filename' => $filename,
1054                'url' => $url,
1055                'thumbUrl' => $url,
1056                'kind' => 'image',
1057                'mime' => 'image/jpeg',
1058                'width' => $width,
1059                'height' => $height,
1060                'bytes' => $bytes,
1061            ];
1062        };
1063
1064        $assets = ['sg-cover' => $plate(28, 'sudhaus-kupferkessel.jpg', 2400, 1600, 862208)];
1065        $assets['sg-cover']['meta'] = [
1066            'caption' => ['en' => 'Copper kettles in the brewhouse', 'de' => 'Kupferkessel im Sudhaus'],
1067        ];
1068        $assets['sg-clip'] = [
1069            'filename' => 'brauerei-rundgang.mp4',
1070            'url' => 'data:video/mp4,',
1071            'kind' => 'video',
1072            'mime' => 'video/mp4',
1073            'bytes' => 18_874_368,
1074        ];
1075        $assets['sg-brochure'] = [
1076            'filename' => 'brauerei-prospekt-2026.pdf',
1077            'url' => 'data:application/pdf,',
1078            'kind' => 'file',
1079            'mime' => 'application/pdf',
1080            'bytes' => 939_305,
1081        ];
1082        $assets['sg-terms'] = [
1083            'filename' => 'agb-gastronomie-lieferung.docx',
1084            'url' => 'data:application/octet-stream,',
1085            'kind' => 'file',
1086            'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1087            'bytes' => 46_490,
1088        ];
1089
1090        for ($i = 1; $i <= self::GALLERY_SIZE; $i++) {
1091            $assets[self::galleryUid($i)] = $plate(
1092                ($i * 47) % 360,
1093                sprintf('brauerei-rundgang-%02d.jpg', $i),
1094                1800,
1095                1200,
1096                300000 + ($i * 41213),
1097            );
1098        }
1099
1100        // Catalog text a neutral image block shows in the site's default locale.
1101        $assets[self::galleryUid(1)]['meta'] = ['caption' => ['en' => 'First stop of the brewery tour']];
1102
1103        return $assets;
1104    }
1105
1106    /**
1107     * @return array<string, array<string, mixed>>
1108     */
1109    private function richtextContent(): array
1110    {
1111        $doc = static fn(string $heading, string $text): array => [
1112            'type' => 'doc',
1113            'content' => [
1114                [
1115                    'type' => 'heading',
1116                    'attrs' => ['level' => 2],
1117                    'content' => [['type' => 'text', 'text' => $heading]],
1118                ],
1119                ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => $text]]],
1120            ],
1121        ];
1122
1123        return [
1124            'rtDefault' => [
1125                'value' => ['zxx' => $doc('Sudhaus', 'Die neue Maischepfanne wird im Herbst eingebaut.')],
1126                'format' => Envelope::FORMAT,
1127                'version' => Envelope::VERSION,
1128            ],
1129            'rtTrimmed' => [
1130                'value' => ['zxx' => $doc('Presse', 'Nur Fett, Kursiv, Link und die Quelltextansicht.')],
1131                'format' => Envelope::FORMAT,
1132                'version' => Envelope::VERSION,
1133            ],
1134        ];
1135    }
1136
1137    /**
1138     * Code descriptors: a field offering several syntaxes, a translated one
1139     * whose empty German value shows the English fallback, and the
1140     * read-only state.
1141     *
1142     * @return list<array<string, mixed>>
1143     */
1144    private function codeFields(Controls $controls): array
1145    {
1146        $control = Control::code()->resolve($controls)->array();
1147
1148        return [
1149            [
1150                'name' => 'codeEmbed',
1151                'label' => 'Code â€” html, css, javascript',
1152                'control' => $control,
1153                'syntaxes' => ['html', 'css', 'javascript'],
1154            ],
1155            [
1156                'name' => 'codeConfig',
1157                'label' => 'Code â€” translated yaml',
1158                'control' => $control,
1159                'translate' => true,
1160                'syntaxes' => ['yaml'],
1161            ],
1162            [
1163                'name' => 'codeReadonly',
1164                'label' => 'Code â€” read-only',
1165                'control' => $control,
1166                'immutable' => true,
1167                'syntaxes' => ['json', 'php'],
1168            ],
1169        ];
1170    }
1171
1172    /**
1173     * @return array<string, array<string, mixed>>
1174     */
1175    private function codeContent(): array
1176    {
1177        $embed = <<<'HTML'
1178            <style>
1179                .map { aspect-ratio: 16 / 9; }
1180            </style>
1181            <div class="map" data-zoom="12"></div>
1182            <script>
1183                // Loads the map once the frame is visible.
1184                const map = document.querySelector('.map');
1185                map.dataset.ready = 'true';
1186            </script>
1187            HTML;
1188        $config = <<<'YAML'
1189            # Opening hours shown in the footer
1190            days:
1191              - Mon–Fri
1192              - Sat
1193            hours: "11:00–22:00"
1194            closed: false
1195            YAML;
1196        $json = <<<'JSON'
1197            {
1198                "brew": "Lager",
1199                "gravity": 12.5,
1200                "hops": ["Hallertauer", "Tettnanger"],
1201                "filtered": true
1202            }
1203            JSON;
1204
1205        return [
1206            'codeEmbed' => ['value' => ['zxx' => $embed], 'meta' => ['syntax' => ['zxx' => 'html']]],
1207            'codeConfig' => ['value' => ['en' => $config, 'de' => ''], 'meta' => ['syntax' => ['zxx' => 'yaml']]],
1208            'codeReadonly' => ['value' => ['zxx' => $json], 'meta' => ['syntax' => ['zxx' => 'json']]],
1209        ];
1210    }
1211
1212    /**
1213     * Everything `node/inspector` needs, in the shape the editor passes it:
1214     * toggles, route paths per locale, the handle and the fact rows of an
1215     * existing node.
1216     *
1217     * @return array<string, mixed>
1218     */
1219    private function inspector(): array
1220    {
1221        return [
1222            'node' => [
1223                'uid' => 'node-4f21c8',
1224                'handle' => 'sudhaus',
1225                'published' => true,
1226                'hidden' => false,
1227                'paths' => ['en' => '/en/brewery/brewhouse', 'de' => '/brauerei/sudhaus'],
1228                'type' => ['label' => 'Page'],
1229            ],
1230            'locales' => [
1231                ['id' => 'en', 'title' => 'English', 'fallback' => null],
1232                ['id' => 'de', 'title' => 'Deutsch', 'fallback' => 'en'],
1233                ['id' => 'it', 'title' => 'Italiano', 'fallback' => 'en'],
1234            ],
1235            'defaultLocale' => 'en',
1236            'routable' => true,
1237            'renderable' => true,
1238            'pathsUrl' => null,
1239            'generatedPaths' => [],
1240            'meta' => ['created' => 'Aug 11, 2026', 'editor' => 'M. Keller'],
1241            'collapsed' => false,
1242        ];
1243    }
1244
1245    /**
1246     * Listing rows in the shape `collection/row` expects, covering the states a
1247     * real collection rarely shows all at once: tree depth, a collapsed branch,
1248     * the last child of a branch, each status, and a manually ordered group
1249     * whose last row was just moved.
1250     *
1251     * @return list<array<string, mixed>>
1252     */
1253    private function rows(): array
1254    {
1255        $row = static fn(array $overrides): array => array_merge([
1256            'uid' => 'styleguide',
1257            'depth' => 0,
1258            'last' => false,
1259            'guides' => [],
1260            'expanded' => false,
1261            'published' => true,
1262            'hasChildren' => false,
1263            'childrenUrl' => null,
1264            'focusedChildrenUrl' => null,
1265            'childCreateLinks' => [],
1266            'status' => [['kind' => 'published', 'label' => 'Published']],
1267            'cells' => [],
1268            'group' => null,
1269            'moveUp' => false,
1270            'moveDown' => false,
1271            'moved' => false,
1272        ], $overrides);
1273
1274        $cells = static fn(string $title, string $type, string $changed): array => [
1275            ['class' => 'is-bold', 'label' => 'Title', 'value' => $title, 'editUrl' => '#'],
1276            ['class' => '', 'label' => 'Type', 'value' => $type, 'editUrl' => null],
1277            ['class' => '', 'label' => 'Modified', 'value' => $changed, 'editUrl' => null],
1278        ];
1279
1280        return [
1281            $row([
1282                'expanded' => true,
1283                'hasChildren' => true,
1284                'childrenUrl' => '#',
1285                'focusedChildrenUrl' => '#',
1286                'childCreateLinks' => [['url' => '#', 'name' => 'Page']],
1287                'cells' => $cells('Brauerei', 'Page', 'Aug 11, 2026, 10:25 PM'),
1288            ]),
1289            $row([
1290                'depth' => 1,
1291                'childrenUrl' => '#',
1292                'status' => [['kind' => 'unpublished', 'label' => 'Unpublished']],
1293                'published' => false,
1294                'cells' => $cells('Sudhaus', 'Page', 'Aug 11, 2026, 10:25 PM'),
1295                'group' => 'brauerei',
1296                'moveDown' => true,
1297            ]),
1298            $row([
1299                'depth' => 2,
1300                'last' => true,
1301                'guides' => [1],
1302                'status' => [['kind' => 'hidden', 'label' => 'Hidden']],
1303                'published' => false,
1304                'cells' => $cells(
1305                    'A title long enough that it has to be cut off somewhere',
1306                    'Page',
1307                    'Aug 11, 2026, 10:25 PM',
1308                ),
1309            ]),
1310            $row([
1311                'depth' => 1,
1312                'last' => true,
1313                'status' => [
1314                    ['kind' => 'published', 'label' => 'Published'],
1315                    ['kind' => 'changes', 'label' => 'Changes'],
1316                    ['kind' => 'locked', 'label' => 'Locked'],
1317                ],
1318                'cells' => $cells('Presse', 'Page', 'Aug 11, 2026, 10:25 PM'),
1319                'group' => 'brauerei',
1320                'moveUp' => true,
1321                'moved' => true,
1322            ]),
1323        ];
1324    }
1325
1326    /**
1327     * @return array<string, array{value: array<string, mixed>}>
1328     */
1329    private function content(): array
1330    {
1331        return [
1332            'title' => ['value' => ['zxx' => 'Sudhaus wird modernisiert']],
1333            'teaser' => ['value' => [
1334                'en' => 'The brewhouse gets a new mash tun.',
1335                'de' => 'Das Sudhaus bekommt eine neue Maischepfanne.',
1336            ]],
1337            'category' => ['value' => ['zxx' => 'news']],
1338            'weight' => ['value' => ['zxx' => 20]],
1339            'featured' => ['value' => ['zxx' => true]],
1340            'accent' => ['value' => ['zxx' => '#e54231']],
1341            'background' => ['value' => ['zxx' => '']],
1342            'locked' => ['value' => ['zxx' => 'node-4f21c8']],
1343            'invalid' => ['value' => ['zxx' => '']],
1344            'overflow' => ['value' => ['zxx' => '']],
1345        ];
1346    }
1347}