kiosk-catalog
Раскладка киоска — сетки каталога, карточки групп и товаров, плашка с названием, строка поиска, пейджер, нижняя панель. Использовать при правке frontend/src/kiosk/App.vue, CategoryGrid.vue, ProductGri
SKILL.md.claude/skills/kiosk-catalog/SKILL.md
GitHubКаталог киоска (frontend/src/kiosk)
Вёрстка рассчитана на экран прибора 1920x1080 (владелец проверил на самом приборе;
в спецификациях вендора значилось 1366x768, и часть решений ниже принята из тесноты
768 px — на 1080 их можно ослаблять). Меняя размеры, проверяйте именно это разрешение,
а не окно ноутбука. Тач-экран без мыши: цели касания ≥ 64 px, никаких
состояний :hover.
Устройство экрана
- Порядок карточек живёт в
shared/catalog.ts— общий для киоска и админки. Товары: API отдаёт по коду, киоск переворачивает, и первыми идут последние артикулы. Группы: пока порядок не задан руками — то же самое (алфавит наоборот); как только оператор перетащил их в админке, список показывается как есть — заданный порядок и есть порядок, переворачивать его значило бы спорить с оператором. Переворот в киоске, а не в API, чтобы админка и заливка видели каталог по порядку кодов. - Порядок групп хранится в
category_covers.sort_order(NULL — не задан; такие группы идут после заданных, по алфавиту, чтобы новая группа из 1С не вклинивалась в середину). Меняется перетаскиванием в окне «Обкладинки груп» (PUT /api/products/categories/order, список имён целиком) и доезжает до киоска тем жеlive.notify("catalog"), без перезагрузки. - Единицу рядом с ценой («кг», «шт») убирает настройка
kiosk_show_unit: в отделе, где всё на вес, она одна на весь прибор и только удлиняет строку, из-за которой число приходится ужимать. Касается карточки товара и плитки «Ціна» в шапке; показание веса и «Вартість» с валютой остаются — там единица говорит о разном. В нижней плашке выбранного товара дробь «грн/кг» тоже уходит, а сам вес остаётся: строка под названием — пояснение, а не крупное число. - Снимок в карточке всегда вписан целиком (
object-fit: contain), а размер задаёт один масштаб: 100 % — фотография помещается без обрезки, больше — растёт и обрезается краями карточки, меньше — стоит с полями. Способ вписывания не переключается порогом:contain↔coverдавали скачок ровно на том значении, где оператор ждёт разницы в один процент — при 89 % снимок целый, при 90 % внезапно крупный и обрезанный. Порог убран, шкала монотонная. - Масштаб снимка внутри карточки настраивается отдельно для товара
(
ui_photo_scale,--ui-photo-scale) и для группы (ui_photo_scale_group,--ui-photo-scale-group): обложка группы — снимок первого товара, а карточка группы крупнее товарной, и один и тот же кадр в них смотрится по-разному. - Настройка
kiosk_photo_first(по умолчанию выключена) — мягкий вариант той же заботы: товары без снимка остаются, но уходят в конец. Сортировка устойчивая, поэтому внутри каждой половины порядок прежний, с конца. Вместе сkiosk_only_with_photoбессмысленна и в админке блокируется. - Настройка
kiosk_only_with_photo(по умолчанию выключена): товар без снимка на экран не попадает и по коду не находится, группа без таких товаров уходит вместе с ним. Фильтр — computedproducts/categoriesповерх сырыхloadedProducts/loadedCategoriesвApp.vue, так что всё остальное (поиск, код, страницы) о нём не знает. Проверять после перезагрузки страницы: во вкладке в фоне TransitionGroup не доигрывает уход карточек, и старые остаются в DOM сopacity: 0. - Каталог двухуровневый: карточки групп -> карточки товаров. Поиск работает поперёк групп и сразу показывает товары.
- Уровень групп необязателен (
kiosk_use_groupsв админке). Прибор вешают на отдел, и тогда весь его ассортимент помещается в сетку товаров, а экран групп становится лишним касанием между покупателем и карточкой. С выключенными группами киоск открывается сразу списком товаров; поиск и набор кода работают так же. - Весы стоят шапкой во всю ширину: показание нужно видеть с любого места у прибора, а не только стоя напротив левого края экрана. Внутри шапки всё в строку — в ней высота дороже ширины, поэтому состояние весов стоит рядом с числом, а не под ним.
- Пока ищут товар по названию, шапка исчезает, и её высота уходит второму ряду
карточек: товар ещё не выбран, показывать нечего, а клавиатура и так перестраивает
экран. Схлопывается трек сетки киоска (
.kiosk.hushed-scale), сама шапка остаётся в разметке: убери её — и каталог встанет в первыйauto-трек, потеряв свой1fr. При наборе кода шапка на месте — в ней стоит сама кнопка «за Кодом». - До первого контакта покупателя шапки тоже нет (
engagedвApp.vue, тот же.kiosk.hushed-scale; настройкаkiosk_header_on_contact, по умолчанию включена — выключают там, где показание должно быть видно всегда): пустой экран с нулями и «0,00 грн» читается как сломанный прибор, а карточкам высота нужнее. Контакт — касание экрана или груз на платформе (net_g >= min_print_weight_g); конец сессии (reset(): товар забрали, простой) снова прячет шапку. Касание доводит доengageтолько самclick(слушатель наwindow), протяжку —onSwipeEnd: шапка сдвигает сетку на 186 px, и появись она междуpointerdownиclick, касание ушло бы другой карточке, а печать идёт от касания. Первый вариант —setTimeout(0)послеpointerup— не работал: браузер шлётclickотдельной задачей, через кадр, таймер успевал раньше, и первое касание стартового экрана только показывало шапку. Уclickцель уже выбрана, перерисовка после него безвредна. Проверять синтетикой сclickв отдельной задаче (setTimeout40 мс послеpointerup): высота шапки 0 до клика, категория открылась. - Состояние весов красит рамку плитки показания, а не всей шапки: дрожание веса — свойство показания, а цена, сумма и набор кода к нему отношения не имеют. Спокойное состояние — тот же второстепенный цвет, что у соседних плиток: «стабильно» это норма, а не событие, ради которого шапке стоит менять вид; взвешивание красит рамку предупреждением, ошибка — тревогой.
- Над показанием — только состояние связи. Часов нет: время покупателю у весов не нужно, а строка под него занимала место и тянула взгляд на себя.
- Стабильный вес подписью не сообщается: это норма, и рамка плитки её показывает. Остаются взвешивание и ошибки — то, что требует что-то сделать.
- Все числа шапки одного кегля (
38px× ползунок весов): вес, цена и стоимость — величины одного порядка важности, и разный размер делал из них иерархию, которой нет. - Шапка собрана из одинаковых плиток: показание, цена, стоимость и кнопка набора кода —
один радиус, одни отступы, одна высота. Первые три ещё и равной ширины (по
1fr): это величины одного порядка, и разный размер делал из них иерархию, которой нет. Обёрткаdlвокруг цены и стоимости не занимает колонку — онаdisplay: contents, и обе плитки встают в сетку шапки сами. Иначе шапка читается как число, к которому сбоку что-то приставили. - Подписи всех плиток стоят на одной строке, и числа под ними — тоже. Держится это на трёх мелочах: содержимое плитки прижато к верху (при центрировании подписи разводит разной высотой содержимого), строка подписи имеет точную высоту (у строки связи есть кружок индикатора, и по содержимому она выходила ниже), а кегль подписи и интерлиньяж чисел одинаковы во всех плитках — от них считается высота строки.
- Цена и стоимость — то, ради чего товар кладут на платформу: цена обведена акцентом, стоимость им залита целиком. Вид блоков не зависит от того, выбран ли товар: пока не выбран, в них стоят нули, а не прочерки. Меняется только число — экран не перекрашивается на каждом выборе.
- Кнопку набора кода можно убрать совсем (
kiosk_code_buttonв админке): в отделе с полусотней позиций код никто не набирает, а место рядом с ценой и стоимостью она занимает. Без неё четвёртая колонка шапки не строится вовсе — пустой трек оставлял бы справа зазор, и шапка выглядела бы смещённой влево. Замер на 1920x1080: три плитки по 488 px вместо 390 + 390 + 390 + 342. - Кнопка «за Кодом» стоит в шапке, а не в строке поиска: покупатель, который знает код,
не ищет глазами строку поиска. Красится она цветом каталога (как «ПОШУК товару») и
вдавливается, пока блок цифр открыт. Класс у неё
code-toggle, а неcode: так уже называется код товара на карточке. - В строке поиска остались заголовок каталога и само поле.
- Кнопок тары и нуля в шапке нет: место там дороже, и оно ушло цене и стоимости.
Сами кнопки стоят в нижней панели и включаются настройкой (см. скилл
admin-ui). - Отдельной строки состояния нет: связь показывается в самом блоке весов, за которым покупатель и так следит — строка ради трёх слов не окупается.
- Пустая платформа сообщением не считается: подсказка «покладіть товар» висела бы почти всё время и превращалась в шум. Строка состояния сохраняет высоту, чтобы шапка не дёргалась при появлении текста.
- Нижняя панель собрана теми же плитками, что и шапка, и читается слева направо: чем
управляет оператор, что выбрано, что дальше. Слева тара и обнуление (плитки
показываются по настройке
kiosk_scale_buttons), в середине выбранный товар, справа действие. Суммы в футере нет: она стоит в шапке рядом с ценой, и держать одно число в двух местах экрана незачем. - Действие всегда справа, под большой палец, и всегда акцентное. Состояний два:
«Усі товари», когда есть куда возвращаться (открыта группа, идёт поиск или набран
код), иначе «ПОШУК товару». Считается это от самих причин (
canReturn), а не от «мы не на верхнем уровне»: без групп верхний уровень и есть список товаров, и возврат там вёл бы в никуда. Кнопки печати нет вовсе: этикетка выходит от касания карточки, и подтверждать выбор ещё одним нажатием значит задерживать покупателя у прибора. Возврат живёт только здесь — в строке поиска его больше нет. Поиск при этом остаётся действием верхнего уровня: он и так работает поперёк групп, начинать его изнутри группы незачем. - Ширина у слота одна на все состояния (
min(300px × ползунок футера, 62vw)) — иначе он прыгал бы на каждой смене надписи. Это одно и то же место экрана, поэтому и цвет один. Верхний предел считается от ширины экрана (vw), а не от строки: доля строки для grid-элемента считается от его же ячейки, и кнопка вместо ограничения получала произвольное число. - Правая часть футера — две кнопки пополам (
.actions,grid-template-columns: 1fr 1fr): слева «Усі товари» (только ниже верхнего уровня), справа «ПОШУК товару» — всегда. На верхнем уровне левая половина остаётся пустым.action-gap: поиск не переезжает между экранами, и палец не попадает по кнопке, которая только что встала под него. Ширину задаёт ячейка, а не кнопка, иначе надпись растягивала бы половину под себя. Обе кнопки и доля ширины настраиваются в админке (kiosk_search_button,kiosk_back_button,kiosk_search_width— проценты поиска, остальное возврату). Две колонки живут только когда включены обе кнопки: одна забирает ширину целиком, а при обеих выключенных блока нет вовсе. Выключать возврат можно лишь там, где групп нет (kiosk_use_groups), иначе из группы не выйти до сброса по простою. - Настройка
kiosk_actions_full_width: плитка выбранного товара и ряд кнопок делят нижнюю панель поровну — одна кнопка занимает половину панели, две по четверти. При обычной раскладке (auto 1fr auto) кнопки забирают четыре пятых, а ширина плитки скачет вслед за длиной надписи. Режим половин сделан на flex, а не на колонках сетки: тара и обнуление появляются по настройке, и число колонок менялось бы вместе с ними. Пустая половина.action-gapдержится только в обычной раскладке; в режиме половин её нет — иначе одинокий поиск занимал бы четверть вместо половины. - Название выбранного товара в плитке переносится на вторую строку, а не обрывается
многоточием (
line-clamp: 2): имена из 1С длинные, и по обрезку покупатель не поймёт, что взвешивает. Третья строка растила бы футер — её отсекаем. - Отступы плиток футера скромнее, чем в шапке: вдвоём они и так забирают у карточек больше трети экрана прибора.
- Не прокручивается ничего: страница фиксирована, лишнее уходит в пагинацию. Элементу
внутри grid-трека нужен
min-height: 0, иначе он растёт по контенту и перекрывает соседей.
Сетка
- Сетка каталога не прокручивается: страница фиксирована (
grid_colsxgrid_rowsдля групп,product_grid_colsxproduct_grid_rowsдля товаров — сетки разные, групп полтора десятка, а товаров в группе больше), лишнее уходит в пагинацию. Обрезанный наполовину ряд на киоске выглядит как поломка, поэтому карточка тянется по высоте ряда, а фото забирает остаток. По умолчанию обе сетки 4x2: при трёх рядах карточка на экране прибора сжимается до 131 px и фото перестаёт читаться. - Размеры подписей и доля высоты под фотографию задаются в админке и приезжают в
вёрстку CSS-переменными на корне киоска (
--ui-*). За теми же ползунками идут не только шрифты, но и сами блоки: отступы плиток шапки и футера, ширина кнопок, размер иконок. Зашитые пиксели означали бы, что настройка масштаба меняет надпись, но не кнопку под ней.
Карточка
- В карточке товара код лежит поверх фотографии без подложки, название слева, цена
справа в той же строке: взгляд идёт от «что» к «сколько», а код нужен только тем,
кто набирает его на клавиатуре. Показывать ли его вообще, решает настройка
kiosk_show_code: в отделе, где ищут глазами, код только занимает угол снимка. Показывается он со словом «Код» — голое число рядом с ценой читается как вторая цена. - Подпись карточки (и товара, и группы) — плашка во всю текстовую часть карточки, а не полоса под текстом: так подпись читается как отдельная панель. Плашка всегда во всю ширину карточки; в настройках — её высота и цвет. Цвет текста не настраивается, а выводится из яркости заливки, иначе одного неудачного выбора хватит, чтобы подпись пропала.
- Высоту карточки делит одна настройка — высота плашки: плашка занимает ровно заданную долю, фотография забирает остаток. Две настройки на одну и ту же высоту всегда спорят, и одна из них оказывается обманом: доля фото раньше держала плашку снизу, и ползунок высоты ниже этого предела переставал что-либо менять. Отдельных ползунков доли фото в админке больше нет.
- Пола «не ниже двух строк подписи» тоже нет — он был вторым барьером у того же
ползунка. Вместо него число строк следует за высотой: ниже 18 % подпись сворачивается
в одну строку (
--ui-plate-lines), иначе на низкой плашке вторая строка срезалась бы посередине букв. - Масштаб снимка внутри карточки задаётся в админке (
ui_photo_scale, проценты). 100 — фотография заполняет отведённое место и кадрируется по краям, как было всегда; больше — приближение, меньше — снимок стоит целиком, с полями цвета подложки. Снимки приходят из товароучёта снятыми по-разному, и одному кадру нужна обрезка, а другому наоборот. Блок фотографии обрезает содержимое (overflow: hidden) — без этого приближённый снимок наползал бы на плашку с названием. - Читаемость подписи на низкой плашке — на совести оператора: рядом есть ползунок размера названия, и уменьшать плашку осмысленно вместе с ним.
- По плашке идёт перелив — тот же приём, что у собранной надписи в заставке: полоса
света поперёк с периодом 1600 мс. Переливается свет, а не оттенок: цвет плашки задаёт
оператор, и рисунок обязан работать на любой заливке. Полоса едет
transformом — композитор рисует её сам, без перерисовки карточки, — а карточки в сетке стартуют с разных фаз (--i), поэтому волна идёт по ряду. Приprefers-reduced-motionперелива нет. - Обрезка названия по строкам живёт на вложенном элементе: у flex-элемента браузер
приводит
displayкflow-root, и-webkit-line-clampне работает.
Цвета элементов каталога
- Основным цветом из настроек покрашены плашки карточек, их рамки (1 px) и кнопка
«за Кодом» — всё, что относится к каталогу. Второстепенный достаётся действиям и
итогам: печати, поиску, возврату, стрелкам пейджера, цене, стоимости и сумме. Оба
цвета задаются в админке, подробности — в скилле
kiosk-theme. - Кнопка набора кода стоит на тени и вдавливается, как соседняя «Усі товари»; включённое состояние — та же вдавленность, а не другой цвет: панель цифр на экране и так видна, а смена цвета на произвольной заливке из настроек читалась бы хуже.
- Стрелки пейджера выглядят как кнопка «Усі товари»: обе перемещают покупателя по каталогу, и разный вид читался бы как разное назначение.
- Обе стрелки собраны справа, вокруг счётчика: «назад», счётчик, «вперёд». Листают ими подряд, и разведённые по краям экрана прибора они заставляли бы перекладывать руку с одного края на другой. Счётчик между ними объясняет, что делают соседние кнопки; точки страниц занимают всё оставшееся место слева.
Строка поиска и заголовок
- Выбранный товар сетку не меняет: развёрнутой карточки на весь блок больше нет.
Показывать её было незачем — касание карточки сразу печатает этикетку, и экран
выбора живёт доли секунды. Выбранная карточка лишь подсвечивается (
.card.active), а сумма и цена видны в шапке. - Поля поиска не видно, пока поиск не вызвали: набирать негде и незачем, а кнопки «ПОШУК товару» и «за Кодом» и так на виду. Поле появляется вместе с клавиатурой и остаётся, пока в нём что-то набрано. Пустая строка головы схлопывается, и карточки встают вплотную к весам: держать 64 px ради невидимого поля дороже, чем сдвиг сетки в тот момент, когда поиск открывают — он всё равно перестраивает экран.
- Пока набирают код, строка головы пустеет: заголовок «Пошук за кодом …» и возврат
оттуда убираются. Набранное видно на самом блоке цифр, возврат ко всем товарам стоит
в нижней панели, а поле поиска в этот момент и так закрыто. Прячется содержимое
строки, а не сама строка: убери её из разметки — и ряды колонки сдвинутся, блок
карточек потеряет свой
1fr, а заберёт его пейджер (проверено: пейджер раздулся до 429 px, карточки сжались до 177). - Заголовок каталога и поле поиска стоят в одну строку: слева от заголовка всё равно пусто, а ряду карточек эта высота нужнее. Поиск при этом прижат к весам и уведён подальше от кнопки, которая его открывает.
- Возврата «Усі товари» в строке поиска нет — он переехал в слот действия футера.
Строка головы от этого схлопывается на верхнем уровне, и карточки встают вплотную к
весам. Окно в 350 мс после возврата (
backJustHappened) осталось и стало нужнее: на месте нажатой кнопки возврата встаёт «ПОШУК товару», а браузер умеет продублировать клик по элементу, оказавшемуся под пальцем — без окна поиск открывался бы сам собой. - Заголовок в блоке каталога показывается только там, где что-то сообщает: название открытой группы и «результаты поиска». Над сеткой групп подписи нет — карточки говорят сами за себя.
Прочее
- Классы в разных компонентах не должны совпадать по именам: scoped-стили киоска уже
ловили коллизию
.right(шапка против колонки каталога).