# Зоны, блоки и сетка

Дизайн-система: из чего собирается страница помимо содержимого раздела.

## Зоны

Зона принадлежит **раскладке** (`zones.layout_id`). Раскладок у сайта
несколько: `kind = site` — страница сайта (такая одна), `kind = card` —
шаблон карточки объекта, и таких у компонента сколько угодно. Всё, что
описано ниже про зоны и блоки, одинаково работает и там, и там; про карточку
отдельно — «Карточка объекта из блоков».

> ⚠️ Любой запрос зон обязан фильтровать по `layout_id`. Без фильтра зоны
> шаблона карточки высыпятся на страницу сайта — в шапке появится цена товара,
> которого там нет.

Зона — горизонтальная полоса страницы. Полоса задаётся полем `band`:

```
above_content   во всю ширину над рядом (шапка, слайдер)
content_left    ЗОНА СЛЕВА             — системная
content         центральная колонка    — стопка зон, среди них якорь
content_right   ЗОНА СПРАВА            — системная
below_content   во всю ширину под рядом
footer          подвал
```

Боковые полосы стоят не под контентом, а рядом с ним — см. «Зоны слева
и справа от контента» ниже.

Полоса `content` — центральная колонка, и зон в ней сколько угодно; идут они
по `sort_order`. Платформа заводит три: «Над контентом», «Контент» и «Под
контентом». Средняя помечена `is_anchor` — в неё едет содержимое страницы,
и её удаление оставило бы сайт без места под него.

⚠️ **Содержимое печатается ровно в одну зону — ту, что с флагом якоря.**
Раньше рендерер вставлял его в КАЖДУЮ зону полосы, и заведённая рядом «полоса
каталога» давала на странице два заголовка H1 и две карточки товара подряд.
Из этого следует:

- `SiteLayout::contentZone()` ищет `is_anchor`, а не «первую системную зону»:
  системных зон в полосе теперь три, и содержимое уехало бы в верхнюю;
- у системной зоны нельзя сменить полосу и нельзя её выключить — форма эти
  поля просто не применяет (удаление запрещено и подавно);
- запрет заводить свои зоны в полосе контента снят: причина исчезла вместе
  с печатью содержимого во все зоны.

**Зона содержимого выводится всегда**: правило видимости по устройству на неё
не действует, а выключенная — не отменяет содержимое, оно печатается голым
на месте своей полосы. Скрытая зона контента означала бы телефон, которому
отдали шапку, подвал и пустоту между ними.

### Фон зоны и прижатый подвал

Зона всегда во всю ширину экрана, а её содержимое сидит в «стакане». Поэтому
у фона свой выбор: **во всю ширину** (умолчание — так красят шапку и подвал)
или **только по ширине содержимого** (`bg_boxed`) — тогда стили фона уезжают
на `.kz-zone__inner`, а сама зона остаётся без него.

Картинка фона со своими настройками — положение, размер, повтор, «не
прокручивать вместе со страницей» — колонки `bg_media_id`, `bg_position`,
`bg_size`, `bg_repeat`, `bg_fixed`. Они были в схеме с самого начала, но
ни формы, ни вывода у них не было: зона умела только цвет.

⚠️ Адрес картинки резолвится **в рендерере, одним запросом на страницу**,
а не кладётся в кэш раскладки: адрес содержит логин сайта, а раскладка
переживает переименование арендатора. Фильтр по сайту обязателен и там,
и в форме — иначе подменой числа фоном страницы стал бы файл соседа.

**Подвал прижат к низу окна**: страница — колонка во всю высоту экрана,
а подвалу достаётся весь остаток (`margin-top: auto`). Без этого на короткой
странице подвал висел посреди экрана, и под ним белела пустота до самого низа.

### Настройки зоны — колонки, а не JSON

Около сорока полей: контейнер и его ширина, количество колонок, оба отступа
сетки, выравнивание, высота, поля и отступы, цвета текста, ссылок, иконок
и фона, фоновое изображение с позицией, фиксацией и параллаксом, флаги шапки
и подвала, фиксация при прокрутке, CSS-классы, видимость по устройству.

Почему колонками: их конечное число, блоки от них наследуют («применить ко всем
блокам зоны»), и админке нужно по ним фильтровать. Сорок колонок на таблице
в десяток строк на сайт не стоят ничего, а JSON лишил бы и типов, и индексов.

> Легаси хранил всё это JSON-блобом в `Message2000.setting`, а позицию зоны —
> магическим числом `zone_position` (1 — шапка, 2 — левая колонка, 3 — полоса
> контента, 5 — подвал, 6 — мобильное меню).

## Зоны слева и справа от контента

Зоны умеют стоять не только друг под другом. Две полосы — `content_left`
и `content_right` — ставят свои зоны **рядом** с содержимым:

```
ШАПКА                                     (во всю ширину)
[ЗОНА СЛЕВА][ над контентом + КОНТЕНТ + под контентом ][ЗОНА СПРАВА]
ПОДВАЛ                                    (во всю ширину)
```

Обе заводятся вместе с сайтом системными и пустыми: «добавьте зону в полосу,
которой нет в списке» объяснить владельцу нечем. Пустая зона не выводится
вовсе и ничего не стоит.

### Арифметика колонок

**Ширина центра не хранится нигде.** Хранятся только ширины боковых (`span`,
2–10 колонок из 24), а центр считается как остаток от **видимых** боковых:

| На странице | Слева | Контент | Справа |
|---|---|---|---|
| обе боковые с блоками | 6 | **12** | 6 |
| справа блоков нет | 6 | **18** | — |
| пусто с обеих сторон | — | **24** | — |

«Пусто» здесь означает «ничего не отрисовалось на этой странице», а не «нет
блоков в базе»: блок, скрытый правилами видимости, городом или устройством,
схлопывает колонку вместе с собой. Ровно это делало легаси, и без этого
контент был бы заужен на каждой странице, где сайдбару нечем заполниться.

Форма в админке показывает три числа и правит любое из них — правка центра
забирает колонки у правой, а если ей некуда, у левой. Сумму 24 нечем
нарушить: два числа задают третье. Ограничение одно — боковые вместе
не больше 16 колонок, иначе контенту не остаётся и трети.

### Что попадает в ряд

**Полоса «контент» — это центральная колонка**, и в ней стопка зон: над
содержимым, само содержимое и под ним. Платформа заводит три сразу —
«Над контентом», «Контент» (якорь) и «Под контентом», — потому что в легаси
эта пара была захардкожена и владелец ждёт их на месте. Свои зоны туда
добавляются как в любую полосу.

Полосы «над контентом» и «под контентом» — это НЕ центральная колонка:
их зоны идут во всю ширину выше и ниже всего ряда. Свободная зона со
слайдером живёт там. Шапка и подвал в ряд не встают никогда.

⚠️ **Якорь опознаётся флагом `is_anchor`, а не «первой системной зоной»:**
системных зон в полосе теперь три, и содержимое страницы уехало бы в верхнюю.
Запрет заводить зоны в полосе «контент» снят — он существовал, пока
содержимое печаталось в каждую зону полосы и страница выводила себя дважды.

⚠️ Полноширинная зона, оказавшаяся МЕЖДУ зонами ряда, разрывает его: боковые
достаются первому куску, остальные зоны выводятся во всю ширину. Колонка
физически не может стоять рядом с двумя кусками сразу.

### Планшет и телефон

Ряд рассыпается в стопку при ширине ≤ 1024 px: шесть колонок из 24 — меньше
двухсот пикселей, в них не помещается ни меню, ни фильтр. Куда уезжает
боковая, решает её настройка: под содержимое (умолчание — меню и так
в шапке), над ним (фильтр каталога) или никуда.

Отдельных ширин для планшета нет намеренно: сайдбар там либо помещается,
либо мешает, и промежуточного размера у него не бывает.

### Тема

Рендерер отдаёт список из двух видов элементов: зона во всю ширину и ряд
(`kind: 'row'` с ключами `left`, `main`, `right` и посчитанными `spans`).
Тема печатает ряд отдельным шаблоном `row.blade.php`, а зоне внутри ряда
передаёт `inRow: true` — свой «стакан» она не рисует, ширину ей задаёт
колонка. Иначе зона центрировала бы себя по 1240 px внутри узкой колонки
и вылезала из неё.

⚠️ И горизонтальных зазоров у такой зоны нет: 24 колонки по 16 px дают 368 px
одних зазоров — больше, чем сама боковая колонка (298 px при 6/24), и сетка
вылезала наружу вместе со всем содержимым. В колонке блоки всё равно идут
друг под другом.

## Выравнивание содержимого блока

`blocks.align_x` (`stretch` | `left` | `center` | `right`) и `blocks.align_y`
(`top` | `middle` | `bottom`). Блок — колонка флексбокса, и значения едут
в разметку **переменными** `--kz-align-x` / `--kz-align-y`: двенадцать правил
на все сочетания в базовом слое держать нечем, а переменную вёрстка сайта
перебивает одной строкой.

Умолчания повторяют прежний поток (`stretch` и `top`) — иначе появление
настройки сдвинуло бы содержимое каждого блока на каждом сайте.

**Вертикальное выравнивание работает потому, что блоки в ряду сетки
растягиваются на высоту самого высокого.** Логотип, строка поиска, корзина
и кнопка в шапке без него стоят ступеньками — у каждого своя высота,
а содержимое прибито к верху.

## Блоки

Блок живёт в зоне и занимает `span` колонок из `columns` зоны. Есть отдельные
`span_tablet` и `span_mobile`.

Группы полей блока повторяют вкладки формы из админки:

- **содержимое** — тип и источник данных, шаблон вывода, лимит и смещение,
  случайный порядок, обрезка текста, настройки слайдера;
- **дизайн** — фон, цвета, заголовок и его размер, рамка, скругление,
  внутренние отступы, высота, CSS-классы;
- **системное** — закрепление на всех страницах, видимость по устройству,
  скрытие на страницах объектов, номера страниц, `noindex`, TTL кэша.

JSON-колонка `settings` оставлена только под дополнительные параметры
конкретного типа содержимого — то, что не является общим для всех блоков.

## Типы содержимого

`BlockContentTypeRegistry`, встроенные типы:

| Ключ | Что выводит |
|---|---|
| `html` | статичный текст |
| `objects` | N объектов из выбранного раздела |
| `menu` | список разделов |
| `breadcrumbs` | хлебные крошки |
| `copyright` | копирайт с текущим годом |
| `auth` | вход покупателя или приветствие вошедшего — [auth.md](auth.md) |
| `logo` | логотип: картинка, название, слоган, ссылка |
| `cart` | корзина в шапке (модуль `shop`) — [shop.md](shop.md) |
| `form` | форма обратной связи (модуль `forms`) — [forms.md](forms.md) |
| `banners` | баннеры сайта (модуль `banners`) — см. ниже |
| `tabs` | контейнер: вкладками становятся вложенные блоки |
| `group` | колонка: вложенные блоки идут стопкой в своей сетке |
| `object_title` | название объекта — только в карточке |
| `object_body` | текст или краткое описание объекта — только в карточке |
| `object_gallery` | фотографии объекта с миниатюрами и увеличением |

**Персональные типы содержимого** (`auth`, `cart`) реализуют
`PersonalBlockContent`, и рендерер игнорирует у них `cache_ttl`: закэшированная
корзина — это корзина одного покупателя, показанная всем остальным. Настройку
TTL администратор выставить может, но на такой блок она не подействует.

Тип возвращает HTML либо `null`. `null` означает «блока на странице не будет» —
например, вывод из раздела, где не осталось опубликованных объектов: пустой блок
не должен оставлять дырку с заголовком.

Свой тип добавляется классом в модуле плюс одной строкой регистрации:

```php
app(BlockContentTypeRegistry::class)->register(new MyContentType);
```

> Легаси хранил тип числом в JSON-поле `phpset.contenttype` (1 — объекты
> раздела, 2 — меню, 4 — copyright, 5 — создатель сайта, 6 — модуль,
> 7 — хлебные крошки) и разбирал гигантским `switch`.

### Карточка без картинки

Место под картинку в плитке остаётся всегда: у товара без фотографии
выводится серая заглушка (`kz-card__media--empty`), нарисованная прямо
в CSS. Без неё ряд плиток разъезжается — у одного текст начинается
у верхнего края, у соседа на треть карточки ниже, и это читается как
поломка вёрстки, а не как «фотографии нет».

Заглушка только в плитке (`.kz-grid`): в списке новостей или отзывов серый
прямоугольник у каждой записи без фотографии был бы шумом, а выравнивать
там нечего. Разметку даёт общий шаблон `platform::partials.card-media` —
правило одно на каталог, портфолио и всё, что рисуется плиткой.

### Баннеры

Сами баннеры лежат в **общей базе сайта** (таблица `banners`, экран
«Сайт → Баннеры»), а блок только выводит. Причина та же, по которой логотип
стал блоком, но с другим знаком: у баннера нет своей страницы, но одна и та же
картинка живёт **дольше блока** и часто должна переехать в другое место. Держи
её в настройках блока — и переезд означал бы завести её заново, вместе
со сроком и городами.

**Место выбирает баннер, а не блок**: у баннера есть поле «в каком блоке
показывать». Владелец, заводя акцию, решает это один раз в одной форме,
а не ищет потом нужный блок в раскладке. Пусто означает «во всех блоках
баннеров» — у сайта обычно такой блок один.

| Свойство | Зачем |
|---|---|
| Срок показа | акция заводится заранее и гаснет сама; последний день входит в срок целиком |
| Города | тот же механизм, что у объектов: `object_city_rules` с ключом `banner` |
| Ссылка и кнопка | баннер — это переход, а не картинка |

⚠️ **Внешнего ключа на блок сознательно нет.** `cascade` удалял бы баннеры
клиента вместе с блоком, а `nullOnDelete` означал бы «блок убрали — баннер
полез во все остальные». Без ключа удалённый блок просто перестаёт совпадать,
и баннер исчезает вместе со своим местом.

Слайдер сделан прокруткой с привязкой (`scroll-snap`), а не библиотекой: он
листается пальцем и колесом без единой строки скрипта.

Всё, чего прокрутке не хватает на десктопе, добавляет `public/kz-slider.js` —
сотня строк без зависимостей, подключаемая только блоком, которому она нужна:

| Настройка блока | Что делает |
|---|---|
| Стрелки по бокам | листает на слайд вперёд и назад, по кругу |
| Точки-индикаторы | сколько баннеров и какой открыт; клик — переход |
| Листать сам, секунд | 0 — не листать; замирает под курсором |

Плюс **перетаскивание мышью** — оно есть всегда, отдельной настройки у него
нет: на десктопе это то, чего от ленты ждут, а мешать оно не может.

Решения, которые стоит знать:

- **Стрелки и точки показываются только после отработки скрипта** (класс
  `kz-slider--js` вешает он сам). Кнопка, которая ничего не делает, хуже
  отсутствующей — а без JavaScript лента остаётся рабочей.
- **Индикатор загорается сразу при переходе, а не по событию прокрутки**:
  на плавной прокрутке событие приходит с задержкой, а для программной
  часть окружений не присылает его вовсе, и точка отставала бы на слайд.
- ⚠️ **Перетаскивание не открывает ссылку баннера**: клик после сдвига больше
  шести пикселей отменяется. Без этого любая попытка пролистать мышью уводила
  бы с текущей страницы.
- **Пальцем — штатная прокрутка**, скрипт в неё не вмешивается: перехват
  убил бы инерцию и привязку к слайду.
- Один баннер — ни стрелок, ни точек, ни перетаскивания: листать нечего.

Статистики показов и кликов нет — решение владельца.

Копия сайта (`CopySite`) баннеры не забирает: блок для них приезжает,
а картинки и акции у нового клиента свои. Выгрузка сайта — забирает, это
обычная персайтовая таблица.

### Строка поиска

Настройки блока: подсказка в поле, надпись на кнопке (пусто — кнопки нет,
поиск уходит по Enter) и **быстрый поиск** — выпадающий список найденного
прямо при вводе.

⚠️ **Подсказки не работали ни на одном сайте.** Разметка блока была написана
на Alpine, а страницы сайта не грузят никаких библиотек вовсе — и это было
незаметно, потому что форма без подсказок ищет по Enter как обычно. Теперь
подсказки — свой скрипт `public/kz-suggest.js` (сотня строк без зависимостей),
и подключает его тот блок, у которого настройка включена.

Форма занимает блок целиком: ширину ей задают колонки сетки, и вторая ширина
у самой формы означала бы, что настройка блока ни на что не влияет.

Подсказки отвечают **и товарами, и разделами** — сначала товары, потом
категории: человек ищет вещь, а раздел это способ до неё добраться. Разделы
берутся из кэша дерева, а не из поискового индекса: их сотни, а не сотни
тысяч, дерево и так в памяти, и строки в индексе означали бы переиндексацию
сайта после каждого переименования раздела. Последняя строка списка ведёт
на обычную страницу результатов.

**Выпадашка оформлена в базовом слое целиком и нейтрально** — белая карточка,
серые разделители, блёклая подпись «Категория». Такие вещи вёрстка сайта
обычно не трогает вовсе, поэтому они должны выглядеть законченными сразу,
а фирменного цвета в них нет намеренно.

### Меню разделов

У блока `menu` четыре варианта вывода — вертикальное, горизонтальное, плитки
с картинками, кнопки — и глубина до трёх уровней. Источник (корень меню)
задаётся полем блока «раздел для вывода».

**Вариант вывода — это класс на одном и том же списке**, а не свой шаблон
на каждый вид: разметка у всех четырёх одинаковая (список, ссылка, вложенный
список), различается раскладка. Четыре копии одного шаблона разошлись бы
на первой же правке.

| Настройка | Что делает |
|---|---|
| Вариант вывода | `kz-menu--vertical` / `--horizontal` / `--tiles` / `--buttons` |
| Сколько уровней | 1–3; «два» — это раздел и его подразделы |
| Как раскрывать | выпадашкой при наведении, по стрелке, сразу развёрнуто |
| Картинки разделов | берутся из `sections.image_media_id`; у плиток включены сами |
| Исключить текущий раздел | для меню «остальные разделы» |

**Раскрытие работает без JavaScript.** Выпадашка — `:hover`, «по стрелке» —
спрятанный чекбокс с подписью-стрелкой. Скрипт ради раскрытия меню означал бы,
что меню не работает, пока он грузится.

⚠️ **Ветка текущего раздела приходит уже открытой**: `checked` ставит сервер,
а не браузер. Иначе меню «доезжало» бы после загрузки, а на своей странице
владелец видел бы свёрнутый список вместо места, где он находится.

Подсветка текущего раздела — тоже работа сервера: `is-current` на самом
разделе, `is-open` на всей цепочке до него.

### Хлебные крошки и главная

Разделы верхнего уровня лежат **рядом** с главной, а не под ней, поэтому
цепочка предков у них пуста. Главная подставляется в крошки явно — иначе
у половины страниц сайта крошек не было бы вовсе. На самой главной крошки
не выводятся.

## Карточка объекта из блоков

Страница объекта собирается тем же механизмом, что и сама страница: зоны
стопкой, блоки в сетке из 24 колонок. Легаси имел три фиксированных шаблона
карточки товара, и четвёртый означал правку кода — отсюда бесконечная очередь
за «ещё одним вариантом».

**Шаблон карточки** — это раскладка `kind = card`. Она бывает **общей**
(`component_key = NULL`) и **своей у компонента**:

- **общая** — обычный случай. У новости, документа, фотоальбома и сотрудника
  карточка устроена одинаково: заголовок, текст, снимки. Десяток одинаковых
  шаблонов под каждый тип страниц — это ручная работа, ради устранения которой
  конструктор и написан;
- **своя** — там, где карточка правда другая. У товара есть цена, склад
  и кнопка покупки, которых больше нет ни у кого.

Блоки при этом фильтруются сами: помеченные `CardBlockContent` с непустым
списком компонентов в общий шаблон просто не предлагаются — в нём неоткуда
взяться цене.

### Какой шаблон достаётся разделу

`sections.card_layout_id`, и NULL означает «как у родителя», а не «умолчание»:
значение ищется **вверх по дереву** до первого заданного. Последнее звено —
умолчание: сначала своё у компонента, потом общее. Устройство то же, что
у SEO ([seo.md](seo.md)), и по той же причине: «весь каталог одним шаблоном,
а грузовикам своё» настраивается один раз в корне.

⚠️ Найденный идентификатор всегда сверяется со списком доступных этому
компоненту (своих плюс общих): у раздела могли сменить тип страницы, а ссылка
осталась. Чужой шаблон считается незаданным — иначе новость выводилась бы
блоками, собранными для товара.

⚠️ `where('component_key', null)` даёт `= NULL` и не совпадает ни с чем —
общие шаблоны выпадали бы из проверок «первый в области» и «умолчание ровно
одно». Область выбирается через `whereNull`, и это единственное место, где
общий шаблон требует отдельного кода.

### Запасной путь

Шаблонов у компонента нет вовсе или собранный шаблон пуст — карточку рисует
Blade-шаблон компонента, как до конструктора. Поэтому появление раскладок
не сломало ни одного существующего сайта, а пустой, только что созданный
шаблон не отдаёт посетителю белую страницу.

### Части карточки

Ядро даёт то, что есть у объекта любого компонента: название, текст,
фотографии. Всё остальное объявляет модуль — ядро не знает ни про цены,
ни про склады, ни про способ покупки:

| Ключ | Модуль | Что выводит |
|---|---|---|
| `object_title` | ядро | название или SEO-заголовок объекта |
| `object_body` | ядро | полный текст или краткое описание |
| `object_gallery` | ядро | снимки, миниатюры, увеличение на весь экран |
| `product_codes` | `catalog` | артикул |
| `product_price` | `catalog` | цена со скидкой и зачёркнутой базой |
| `product_stock` | `catalog` | наличие и остатки по складам |
| `product_buy` | `catalog` | кнопка покупки из реестра `PurchaseActions` |
| `product_attributes` | `catalog` | характеристики таблицей |
| `product_similar` | `catalog` | соседи по разделу |

Тип, который живёт только в карточке, реализует `CardBlockContent` и называет
компоненты, чьей карточке подходит (пустой список — любой). Непомеченные типы
доступны и на странице, и в карточке: произвольный текст, форма вопроса или
меню внутри карточки — нормальное желание, и запрещать его нечем.

### Две колонки: блок «Колонка»

⚠️ Сетка зоны сама двух колонок не даёт. Она раскладывает блоки подряд
и переносит строку, когда колонки кончились: галерея на 12 и пять блоков
по 12 дают не колонку справа от снимка, а **лесенку** — название встаёт рядом
с галереей, а цена, наличие и кнопка уезжают строками ниже неё. Высоту галереи
сетка не знает и знать не может: она зависит от снимка.

Колонка (`group`) решает это вложенностью — тем же механизмом, что и вкладки:
сама занимает свои 12 колонок в сетке зоны, а внутри у неё **своя сетка
из 24**, где дети идут сверху вниз. Поэтому внутри колонки цена и кнопка тоже
могут встать рядом — достаточно дать им по 12.

```
зона (24)
├── галерея            span 12
└── колонка            span 12
    ├── название       span 24   ← ширина считается от колонки, не от зоны
    ├── артикул        span 24
    ├── цена           span 12   ← цена и кнопка встают рядом
    └── кнопка         span 12
```

Настройки колонки: отступ между блоками и «прилипает при прокрутке» — колонка
с ценой и кнопкой остаётся на экране, пока листают длинное описание. На телефоне
липкость снимается: колонка заняла бы весь экран.

Заголовок блока внутри колонки печатается как обычно — в отличие от вкладок,
где он уезжает на переключатель.

### Вкладки

Вкладка — обычный блок, положенный внутрь контейнера (`blocks.parent_id`),
а её надпись — заголовок этого блока. Поэтому во вкладку кладётся что угодно,
и «Доставка» или «Инструкция по монтажу» не требуют ни строчки кода. Тип
контейнера реализует `AcceptsChildBlocks` и печатает уже отрисованных детей.

Правила:

- глубина ровно одна — контейнер в контейнер не кладётся, поэтому вкладок
  внутри колонки не бывает (и наоборот);
- ребёнок никогда не выводится сам по себе: выключенный контейнер уносит
  вкладки с собой, иначе их содержимое высыпалось бы в страницу простынёй;
- контейнер с персональной вкладкой внутри не кэшируется — типы детей
  проверяются ДО рендеринга, иначе проверка стоила бы столько же, сколько
  сэкономил бы кэш.

Разметка приходит **развёрнутой**: без `kz-tabs.js` все вкладки видны друг
под другом, каждая со своим заголовком. Скрипт лишь добавляет контейнеру
`is-ready`, по которому базовый слой прячет неактивные. Сделай наоборот —
и ошибка загрузки оставила бы карточку без описания и характеристик, а робота
без текста, ради которого страница и существует.

### Кэш блока в карточке

Ключ кэша блока карточки содержит идентификатор объекта. Раздел у всей витрины
один, и без этого первый открытый товар раздавал бы свою цену и своё название
всем остальным на весь TTL. Блокам самой страницы объект в ключ не идёт: меню
и шапка одинаковы на всех карточках, а сто тысяч копий одного меню — это кэш,
который не помогает никому.

## Правила видимости

Модель явная:

> Блок виден в разделе S ⟺
> ( `pin_all_pages` **ИЛИ** `allow` ровно на S **ИЛИ** `allow` с каскадом
> на предке S )
> **И** нет `deny` на S **И** нет `deny` с каскадом на предке S

Три следствия:

- **Deny сильнее всего**, включая закрепление на всех страницах. Иначе правило
  «показывать везде, кроме корзины» было бы невыразимо.
- **Блок без закрепления и без единого `allow` не виден нигде.** Явно, а не
  неявно-глобально: иначе новый блок молча вылезал бы на всех страницах.
- **Каскад работает по цепочке предков**, которая уже лежит в памяти из кэша
  дерева, — проверка бесплатна.

> Легаси хранил видимость в `showing_blocks` и инвертировал **весь** тест
> вхождения флагом `fixblock`: одно поле превращало белый список в чёрный,
> и понять по данным, где появится блок, было нельзя без чтения кода запроса.
> Плюс у таблицы не было ни `Catalogue_ID`, ни первичного ключа.

### Служебные страницы

Корзина, оформление, кабинет и выдача поиска — адреса модулей, а не разделы
дерева. Для посетителя это такие же страницы: с шапкой, подвалом и блоками
вокруг содержимого, — поэтому у них своя строка в правилах.

```
block_page_rules (site_id, block_id, page_key, mode)
```

> Блок виден на служебной странице P ⟺
> ( `pin_all_pages` **ИЛИ** `allow` на P ) **И** нет `deny` на P

Правила разделов на служебных страницах **не действуют вовсе**. Раздел им
всё равно нужен — от него считается подсветка пункта меню, — но раньше вместе
с разделом заимствовались и его блоки: владелец отмечал «только на главной»,
а блок выводился ещё и в корзине. Читалось как ошибка, потому что ею и было.

Каскада здесь нет: у корзины не бывает подразделов.

**Список страниц даёт реестр `SystemPages`**, который наполняют модули: корзину
объявляет магазин, поиск — ядро. Реестр помнит ключ модуля, поэтому сайту
с выключенным магазином корзина в правилах не предлагается — тот же приём,
что у `PurchaseActions` и `PaymentProviders`.

Ключи (`cart`, `checkout`, `account`, `search`) уезжают в базу, поэтому менять
их нельзя: правила отвяжутся молча.

### Дополнительные гейты

Проверяются до правил по разделам, потому что дешевле:

| Гейт | Поле |
|---|---|
| устройство | `visibility_mode`: `all` / `mobile` / `desktop` / `not_app` / `app` |
| страница объекта | `hide_on_object_pages` |
| номер страницы | `pages_allow`, `pages_deny`: `1`, `1-3`, `2,4`, `1-3,7` |
| город | `block_city_rules` |

Мобильное приложение отличается от мобильного браузера по заголовку
`X-Korzilla-App`, а не по User-Agent: подделать одинаково легко, но читать
заголовок однозначно.

Блок, ограниченный списком городов, при невыбранном городе **не показывается** —
«доступно только в городах» означает именно это.

## Сетка: 24 колонки на CSS Grid

```css
.kz-zone__inner {
    display: grid;
    grid-template-columns: repeat(var(--kz-cols), minmax(0, 1fr));
    column-gap: var(--kz-gx);
    row-gap: var(--kz-gy);
}

.kz-block {
    grid-column: span var(--kz-span, 24);
    min-width: 0;
}
```

**Разбиения блоков по строкам в PHP нет.** Ширина блока превращается в
`grid-column: span N`, перенос строк делает браузер.

Три места, где легко ошибиться:

- **`minmax(0, 1fr)` вместо `1fr` обязателен.** Иначе длинное неразрывное слово
  или широкая таблица внутри блока распирают колонку и ломают строку.
  По той же причине у блока стоит `min-width: 0`.
- **Количество колонок и оба отступа — переменные.** Поэтому отступы можно
  менять, не трогая определение колонок.
- ⚠️ **Правила «стакана» зоны — дочерним селектором** (`.kz-zone--fixed >
  .kz-zone__inner`), а не вложенным. Зона бывает внутри зоны: карточка объекта —
  это зоны с блоками внутри зоны содержимого. Вложенный селектор зоны в ряду
  с боковыми обнулял зазоры и сеткам карточки: галерея слипалась с колонкой цены,
  и настройкой это было не починить.

Почему 24, а не 12: на двенадцати нельзя ровно разложить, например, пять
карточек в ряд.

> Легаси считал накопленную ширину блоков в PHP и расставлял классы
> `start`/`end`, а сама сетка была на float с фиксированными пикселями
> (`grid_1 { width: 85px }`). Любое изменение отступов ломало раскладку.

Адаптив: `span_tablet` до 1024px, `span_mobile` до 640px.

## Сборка страницы

```
ResolvePageController
    ├── PageResolver           путь → раздел или объект
    ├── содержимое страницы → строка
    │     ├── раздел           шаблон списка компонента
    │     └── объект           CardLayouts → шаблон карточки есть и не пуст?
    │             ├── да       LayoutRenderer::renderCard() → themes/{theme}/card.blade.php
    │             └── нет      шаблон карточки компонента (Blade)
    └── LayoutRenderer         зоны, видимые блоки, содержимое в зону контента
            └── themes/{theme}/layout.blade.php
```

Порядок вывода зон фиксирован полосами; содержимое страницы вставляется
в **одну** зону — системную зону полосы `content`. Пустая зона не выводится
вовсе — кроме зоны контента, которая появляется даже без единого блока.

Если зоны контента нет вообще (раскладки ещё нет у свежего сайта или зону
выключили), отдаётся голое содержимое — перед первой зоной полосы, которая
ниже контента, то есть до подвала, а не после него. Сайт должен быть
работоспособен до настройки дизайна и не должен терять страницу
от одного переключателя.

**Один сломанный блок не уносит страницу.** Исключение при рендере блока
логируется, блок пропускается. Неизвестный тип содержимого (пришёл из
выключенного модуля) — то же самое.

## За что цепляться из CSS

Разметка зоны и блока даёт четыре зацепки:

```html
<section id="kz-zone-3" class="kz-zone kz-zone--fixed kz-zone--header своё" data-kz-zone="3">
    <div class="kz-zone__inner">
        <section id="kz-block-12" class="kz-block своё" style="--kz-span: 7" data-kz-block="12">
            <h2 class="kz-block__title">…</h2>
```

| Зацепка | Когда годится |
|---|---|
| **`css_classes`** — поле «CSS-классы» в настройках зоны и блока | **основной способ**: класс переживает пересоздание элемента и копирование сайта |
| `kz-zone--{ключ}` | вся зона по её ключу: `kz-zone--header`, `kz-zone--footer` |
| `kz-zone`, `kz-block`, `kz-block__title`, `kz-zone__inner` | общий вид всех зон и блоков темы |
| `id` / `data-kz-zone` / `data-kz-block` | отладка и панель редактора |

⚠️ **К `id` и `data-*` стили привязывать не стоит.** Это первичный ключ строки:
блок удалили и создали заново, сайт скопировали из шаблона — идентификатор
другой, а правило осталось висеть в пустоте. `id` печатается для того, чтобы
до элемента можно было дотянуться из консоли и из скрипта, а не для вёрстки.

## Словарь классов

Зона и блок — это каркас страницы, а внутри блока печатает разметку компонент.
Имена там были случайные и у каждого свои: `news-block`, `catalog__link`,
`staff__grid`, а местами класса не было вовсе. Цепляться было не за что ни
базовому слою, ни верстальщику клиента.

Правило одно: **общая роль плюс частный класс**. Роль стилизует базовый слой
(`kz-base.css`), частный класс — вёрстка сайта.

```html
<div class="kz-listing kz-listing--news">          обёртка экрана списка
    <h1 class="kz-page__title">Новости</h1>
    <div class="kz-list">                          строчный список
        <article class="kz-card kz-card--news">    карточка в списке
            <time class="kz-card__meta">…</time>
            <h2 class="kz-card__title"><a href="…">…</a></h2>
            <p class="kz-card__text">…</p>
```

| Роль | Класс |
|---|---|
| экран списка | `kz-listing` + `kz-listing--{компонент}` |
| вид списка | `kz-list` (строки) / `kz-grid` (плитка) / `kz-gallery` (картинки) |
| карточка в списке | `kz-card` + `kz-card--{вещь}`: `--product`, `--news`, `--person`, `--work`, `--document`, `--office`, `--review`, `--advantage`, `--object` |
| части карточки | `kz-card__media` (+ `--empty`, `--icon`), `__title`, `__meta`, `__text`, `__price`, `__link`, `__contact`, `__actions` |
| страница объекта | `kz-detail` + `kz-detail--{вещь}`, внутри `__media`, `__gallery`, `__meta`, `__price`, `__text`, `__body`, `__actions` |
| заголовок H1 страницы | `kz-page__title` |
| пары «подпись — значение» | `kz-props`, `kz-props__row`, `__name`, `__value` |
| цена и наличие | `kz-price` (+ `kz-price--none`, `--discounted`), внутри `kz-price__value`, `__base`, `__badge`; `kz-stock` (+ `is-in` / `is-out`) |
| баннеры | `kz-banners` (+ `kz-slider`), внутри `kz-banner`, `__image`, `__body`, `__title`, `__text`, `__button` |
| меню | `kz-menu` + `kz-menu--{вид}` + `--{раскрытие}`, внутри `kz-menu__list--{уровень}`, `__item` (+ `is-current`, `is-open`, `has-children`), `__link`, `__name`, `__image`, `__toggle` |
| фильтр каталога | `kz-filter`, `__group`, `__legend`, `__option`, `__count`, `__submit` |
| таблица | `kz-table` |
| текстовый раздел | `kz-text` (+ `kz-text__title`) |
| «ничего нет» | `kz-empty` |
| постраничная навигация | `kz-pagination`, `__link`, `__current` |
| формы, кнопки | `kz-form`, `kz-form__field`, `kz-form__label`, `kz-form__error`, `kz-button` |
| значок | `kz-icon` — берёт цвет из настройки «цвет значков» зоны или блока |
| состояния | `is-current`, `is-selected`, `is-empty`, `is-in`, `is-out`, `is-disabled` |
| контекст блока | к списку добавляется `kz-list--block` / `kz-grid--block` |

Почему роль отдельно от компонента: базовый слой не должен знать, что в системе
есть модуль `staff`. Иначе каждый новый компонент — правка базового CSS, а
компонент из конструктора не получил бы оформления вовсе. Частный класс при
этом остаётся: `kz-card--product` — то, за что цепляется вёрстка клиента, когда
карточку товара надо оформить не так, как остальные.

**Шаблон блока — тот же словарь плюс `--block`.** Заголовок страницы он
не печатает (его печатает сам блок), поэтому `kz-page__title` в нём быть
не может.

**Постраничную навигацию печатает своя заготовка** (`platform::pagination`,
она же по умолчанию у пагинатора): штатные виды Laravel размечены классами
Tailwind, которых на сайте клиента нет, и ссылки выходили голым текстом
в столбик. Номера страниц не печатаются намеренно — на каталоге в сто тысяч
товаров это полоса из сотен ссылок; «назад — N из M — вперёд» работает
на любой странице.

⚠️ Списки почти всех компонентов **вообще не выводили навигацию**: контроллер
листал выборку, вторая страница существовала, а ссылки на неё не было
ни одной. Теперь `{{ $items->links() }}` есть в каждом шаблоне списка.

## Базовый слой `kz-base.css`

`public/kz-base.css` — чёрно-белый каркас: раскладка списков и карточек,
галерея, таблицы и характеристики, фильтр каталога, меню, крошки, постраничная
навигация, формы, кнопки, базовая типографика. Сайт выглядит прилично сразу
после создания, до того как за него взялся верстальщик.

Чего в нём нет намеренно: фирменных цветов, шрифтов, теней, анимаций.
Это одевается сверху.

Подключает его **платформа** (`platform::head`), а не тема: сайт из коробки
обязан быть работоспособен, а не только размечен.

### Свой CSS сайта

Экран «Настройки → Оформление → Свой CSS» — аналог легаси-экрана «CSS».
Три вкладки с редактором кода, как в легаси; редактор разворачивается
на весь экран, Ctrl+S сохраняет не сворачивая ([admin.md](admin.md#на-весь-экран)):

| Поле | Ключ | Куда попадает |
|---|---|---|
| Все экраны | `design.css` | как есть |
| Планшет: 641–1024 px | `design.css_tablet` | `@media (min-width: 641px) and (max-width: 1024px)` |
| Телефон: до 640 px | `design.css_phone` | `@media (max-width: 640px)` |

**Границы — те же, на которых перестраиваются сетка и базовый слой** (640
и 1024), а не легаси-овские 780 и 1280: иначе правка попадала бы мимо того, что
перестраивает сама вёрстка. Планшет — диапазоном: правило для планшета
не достаётся телефону. `@media` платформа ставит сама.

**Хранение — настройки, отдача — адрес `/kz-site.css?v={хэш}`**, как robots.txt
и карта сайта: сайтов сотни, а корень `public` один. Легаси склеивал файл
`bc_custom{время}.css` на диске и гонял его через внешний минификатор. Здесь
CSS сам уезжает в копию и выгрузку вместе с настройками.

- **Версия — хэш содержимого**, а не время сохранения: браузер держит файл
  год (`immutable`) и получает новый ровно тогда, когда CSS поменялся.
- ⚠️ **Устаревшая версия отдаётся с `no-cache`.** Страница, открытая
  до сохранения, просит старый адрес; «хранить год» навсегда связал бы его
  с новым содержимым, и следующая правка до посетителя не доехала бы.
- **Маршрут без группы `web`**: стилям не нужна сессия, а кука сессии в ответе
  мешала бы прокси кэшировать файл.
- ⚠️ **Ссылка вставляется в готовый ответ последней перед `</head>`**
  (`InjectSiteStyles`), а не шаблоном `platform::head`. Тот стоит в начале
  `<head>`, тема печатает свои стили после него, и при равной специфичности CSS
  владельца молча проигрывал бы им. Место «перед `</head>`» от темы не зависит.
- Своего CSS нет — ссылки на странице нет вовсе. Админке CSS сайта
  не подключается: он ломал бы её вёрстку, а не правил.

Не перенесено из легаси намеренно: **поле «Color»** — перекраска в X это
переменные базового слоя (`:root { --kz-accent: … }`) и цвета зоны и блока;
**CSS у каждого блока** — стили цепляются за поле «CSS-классы» зоны или блока
в общем CSS сайта, иначе порядок правил между блоками не контролирует никто.

### Почему `@layer`, а не «подключить пораньше»

Весь файл лежит в слое:

```css
@layer kz-base { … }
```

Правило вне слоя выигрывает у правила в слое **всегда** — независимо от
специфичности и порядка подключения. Значит, вёрстке сайта не нужно ни
`!important`, ни счёт селекторов, ни борьба за место в `<head>`: `.kz-card`
клиента побеждает `.kz-grid .kz-card` базы, даже будучи подключённым выше.

Альтернатива — обернуть каждый селектор в `:where()` — даёт нулевую
специфичность, но тогда и правила самой базы перестают переопределять друг
друга, и порядок внутри файла начинает решать. Слой решает обе задачи разом.

Проверено в браузере: правило `.kz-card { border-top-color: red }`, вставленное
**перед** ссылкой на базу, побеждает.

### Перекраска — это переменные

```css
:root { --kz-accent: #c0392b; --kz-radius: 0; --kz-gap: 24px; --kz-card-min: 300px; }
```

Отсюда же ширина карточки в сетке: число колонок считает браузер
(`repeat(auto-fill, minmax(var(--kz-card-min), 1fr))`), а не PHP. Жёсткое
«четыре в ряд» ломается на первом же планшете.

### Цвета зоны и блока

Настройки «цвет текста, ссылок, кнопок, значков» есть и у зоны, и у блока
и едут в разметку **переменными**, а не готовыми правилами:

| Настройка | Переменная | Что красит в базовом слое |
|---|---|---|
| Цвет текста | `color` + `--kz-text` | обычный текст и то, что база красит явно (заголовки карточек, пустые кнопки) |
| Цвет ссылок | `--kz-link` | `a` — не задана, берётся `--kz-accent` |
| Цвет кнопок | `--kz-button-bg` | фон `button` и `.kz-button` |
| Текст на кнопках | `--kz-button-text` | их надпись |
| Цвет значков | `--kz-icon` | элементы с классом `kz-icon` |

Переменные наследуются сами, поэтому **наследование цветов не написано
ни одной строкой кода**: заданное зоне действует во всех её блоках, пока блок
не задаст своё. Собирается это в одном месте — `ColorStyles::of()`, общем для
зоны и блока: разойдись их правила, и блок внутри перекрашенной зоны выглядел
бы иначе, чем она сама, без причины, видимой владельцу.

⚠️ Значение уходит в атрибут `style` на каждой странице сайта, поэтому формат
проверяется валидацией (`#rrggbb`), а не только пипеткой в браузере.

Без этих настроек цветная шапка не собиралась вовсе: тёмно-синяя ссылка
«Корзина» на фиолетовом фоне не читается, а перекрасить её было нечем —
оставалось лезть в вёрстку темы, то есть звать разработчика ради одного цвета.

### Поля и кнопки — по тегам, а не по классам

Поля ввода печатают полтора десятка шаблонов — модульных, платформенных
и клиентских. Требовать от каждого помнить про класс поля значит однажды
получить голый `input` посреди оформленной формы, поэтому база стилизует
`input`, `select`, `textarea`, `button` по тегу. Общий класс `kz-button`
нужен для другого: ссылка-кнопка (`<a class="kz-button">`) — не `button`.

### Выключается настройкой

«Оформление → Базовые стили → Подключать базовые стили». Выключать имеет
смысл, только если верстальщик пришёл со своим фреймворком и база ему мешает.
Умолчание — включено: новый сайт не должен выглядеть как текст в блокноте.

Адрес несёт метку версии (`/kz-base.css?v={время правки}`) — иначе браузер
посетителя держал бы базу прошлого выката.

Сам CSS живёт в теме (`themes/{ключ}`) или в персайтовом переопределении
(`overrides/{ключ}`), где при желании подменяется и сам `block.blade.php`.

## Кэш

Вся раскладка сайта — один ключ `kz:{site}:layout:v{lv}`: зоны, блоки и правила.
Три запроса на холодном кэше, ноль на прогретом. Шаблон карточки лежит своим
ключом `kz:{site}:layout:{id}:v{lv}`: шаблонов у сайта несколько, и общий ключ
отдавал бы карточке товара зоны карточки новости.

Фильтрация по видимости выполняется **над** закэшированной раскладкой на каждый
запрос. Кэшировать её по разделам нельзя: видимость зависит ещё от устройства,
города и номера страницы, а это комбинаторный взрыв ключей.

Отдельно кэшируется payload блока, если у него задан `cache_ttl`. Ключ включает
идентификатор раздела: один и тот же блок на разных страницах выводит разное.

⚠️ **И город.** От него зависят цены каталога и баннеры с геотаргетингом; без
города в ключе первый зашедший москвич раздавал бы московские цены и московские
баннеры всей стране на весь TTL блока.

⚠️ **У блока карточки — и объект**: подробности в «Кэш блока в карточке».

⚠️ **Массовые изменения — только через `LayoutWriter`.**
`Model::query()->delete()` не поднимает события Eloquent, наблюдатель не
сработает, и кэш останется протухшим.

```php
$writer = app(LayoutWriter::class);

$writer->setSectionRules($block, allow: [['section_id' => 5, 'cascade' => true]]);
$writer->setCityRules($block, allowCities: [10, 20]);
$writer->reorder($siteId, $zoneId, [3, 1, 2]);   // после drag&drop
```

## Тема

```
themes/default/
  layout.blade.php   каркас страницы, обход зон
  zone.blade.php     зона: контейнер, стили, сетка
  block.blade.php    блок: span, оформление, заголовок
  grid.blade.php     стили сетки
```

Порядок поиска шаблонов:
`overrides/{key}/views` → `themes/{theme}` → views модулей → база.
Персайтовое переопределение вёрстки не требует правки платформенного кода.
