kiosk-catalog

Раскладка киоска — сетки каталога, карточки групп и товаров, плашка с названием, строка поиска, пейджер, нижняя панель. Использовать при правке frontend/src/kiosk/App.vue, CategoryGrid.vue, ProductGri

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 (по умолчанию выключена): товар без снимка на экран не попадает и по коду не находится, группа без таких товаров уходит вместе с ним. Фильтр — computed products/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 в отдельной задаче (setTimeout 40 мс после 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_cols x grid_rows для групп, product_grid_cols x product_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 (шапка против колонки каталога).