/* Kantin Self-Order Kiosk -- brand palette shared with app_v2 dashboard (warm cream/orange/charcoal)
   Apple-style "liquid glass": translucent blurred panels over the brand palette, not a neutral iOS grey. */

:root {
  /* Скорость сцен: заливка «заказ принят», экран имени, всплытие карточки
     оплаты. 1 = сжатые тайминги от 07.09.2026, 1.75 = нынешние.
     Заведено одним числом намеренно: владелец правит темп на глаз и по
     видео с планшета, и каждый раз это была правка девяти строк в разных
     местах файла. Теперь — одна.
     На ВРЕМЯ ОЖИДАНИЯ не влияет: сжимали 07.09 не анимацию, а паузу перед
     экраном («окно очень долго грузится») — она осталась короткой. */
  --t: 1.75;

  /* ================= токены и геометрия спеки (§8, §4) ================= */
  --accent: #E2551C;      /* спека помечает как снятый с фото, уточнить по брендбуку */
  --on-accent: #FFFFFF;
  --ink: #111111;
  --ink-muted: #8A8A8E;

  /* Реальную высоту шапки меряет JS (syncBarHeight): формулой её не задать,
     она зависит от отступов, шрифта и safe-area. Со счётной высотой панель
     уезжала ПОД оранжевый баннер (владелец, 25.08.2026). */
  --bar-h: 64px;
  --peek-h: 176px;        /* сколько панели видно в покое: 56 + 68 + отступы */
  --hero-visible: 0.42;   /* доля экрана, на которой герой остаётся виден в OPEN */

  /* Прогресс раскрытия 0..1. Пишет app.js ОДНОЙ переменной, всё остальное —
     calc() от неё: во время жеста не должно быть ни одной перерисовки
     разметки, только композит (спека §9). */
  --p: 0;

  --ease-enter: cubic-bezier(.16, 1, .3, 1);
  --ease-exit: cubic-bezier(.4, 0, 1, 1);
  --ease-snap: cubic-bezier(.22, 1, .36, 1);
  --ease-scroll: cubic-bezier(.32, .72, 0, 1);

  /* Счётчик на плитке и кнопка «−» под ним. Размер ОДИН на двоих: их равенство
     владелец задал явно, и разъехаться они не должны. Менять — здесь.
     Было 26px, увеличено в полтора раза (владелец, 25.08.2026). */
  --card-badge: 39px;
  /* Owner request (Wolt-style reference): content areas (menu grid, cart,
     category strip) should read as white/cool-light-gray, not the old
     warm cream. Brand-warm colors stay reserved for the accent (orange)
     and the attract/hero screens, which don't use --bg. */
  --bg: #F4F6F8;
  --card: #FFFFFF;
  --surface-2: #EEF1F4;
  --text: #1A1A1A;
  --hint: #7C8590;

  --accent: #CC4100;
  --accent-2: #FD622C;
  --accent-weak: rgba(204,65,0,0.10);

  --success: #17B978;
  --danger:  #F4595B;

  --border: rgba(20,24,38,0.08);
  --shadow: 0 2px 6px rgba(20,24,38,0.06), 0 12px 32px rgba(20,24,38,0.08);
  /* soft "tint" shadow for plain white content cards (product grid, cart
     rows) — no border, just a faint lift, per the Wolt-style reference */
  --card-shadow: 0 2px 4px rgba(20,24,38,0.04), 0 8px 20px rgba(20,24,38,0.07);

  --gradient: linear-gradient(135deg, #CC4100, #FD622C);

  /* "Выбрано" state (owner, 18.08.2026): white tile, black label.
     Both are fixed literals, NOT --card/--text: those flip in the dark colour
     scheme (which guests' phones default to) and that is exactly the bug the
     owner reported — a white tile printing white text on itself. */
  --selected-bg: #FFFFFF;
  --selected-fg: #111111;
  --selected-icon: #CC4100;   /* brand orange keeps the mark visible on white */

  /* liquid glass */
  --glass-bg: rgba(255,255,255,0.55);
  --glass-bg-strong: rgba(255,255,255,0.72);
  --glass-bg-soft: rgba(255,255,255,0.38);
  --glass-border: rgba(255,255,255,0.45);
  --glass-blur: blur(22px) saturate(180%);
  --glass-shadow: 0 8px 28px rgba(43,32,20,0.14);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1A1A1A; --card: #232323; --surface-2: #2B2B2B;
    --text: #F3EEE6; --hint: #9A9A9A;
    --accent: #FD622C; --accent-2: #FFB84C; --accent-weak: rgba(253,98,44,0.16);
    --border: rgba(255,255,255,0.10);
    --shadow: 0 2px 6px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.5);
    --card-shadow: 0 2px 4px rgba(0,0,0,0.35), 0 8px 20px rgba(0,0,0,0.4);

    --glass-bg: rgba(43,43,43,0.55);
    --glass-bg-strong: rgba(43,43,43,0.74);
    --glass-bg-soft: rgba(43,43,43,0.34);
    --glass-border: rgba(255,255,255,0.14);
    --glass-shadow: 0 8px 28px rgba(0,0,0,0.45);
  }
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; user-select: none; }
/* html красим тёмным ОТДЕЛЬНО от body: если системе всё-таки останется полоса,
   которую страница не закрасила, она будет тёмной, а не белой — на заставке и
   на фото товара её просто не видно. Основной фон приложения задаёт body. */

body { margin: 0; padding: 0; height: var(--app-h, 100%); background: var(--bg); color: var(--text); overflow: hidden; }
html { margin: 0; padding: 0; height: 100%; overflow: hidden; }
body {
  font-family: "SF Mono", "JetBrains Mono", ui-monospace, "Inter Tight", -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 15px; line-height: 1.4; -webkit-font-smoothing: antialiased;
}

/* 100dvh, а не 100vh (владелец, 20.08.2026: «внизу белая полоска»).
   На iPad 100vh — это высота БЕЗ учёта панелей и полосы жеста, поэтому экран
   приложения не доставал до низа, и снизу проступал фон body — светлый
   --bg, который и читался как белая полоса. dvh считает реальную видимую
   высоту. 100vh оставлен первой строкой как запасной вариант для старых
   браузеров, где dvh не поддерживается. */
/* Высоту задаёт --app-h — реальное число пикселей, которое JS вычитывает у
   браузера и обновляет на каждом изменении размера (см. syncViewportHeight в
   app.js). Именно поэтому полоса снизу уходила, стоило повернуть планшет и
   вернуть обратно: поворот заставлял браузер пересчитать высоту, а сам по
   себе, при первой отрисовке, он её занижал. Единицы vh/dvh/fill-available
   этот промах не лечат — они берут ту же неверную величину.
   100dvh оставлен запасным значением, если JS почему-то не отработал. */
#app {
  height: var(--app-h, 100dvh);
  display: flex; flex-direction: column;
  /* Фирменный фон ПОД обоими слоями. Во время перехода заставка и меню едут с
     разной скоростью, и на стыке возможен зазор в считанные пиксели. Раньше
     сквозь него светил белый фон страницы и читался как полоса. Теперь там
     градиент заставки — зазор, если и мелькнёт, глазу не за что зацепиться.
     Дешевле и надёжнее, чем подгонять высоты слоёв друг под друга. */
  background: var(--gradient);
}

.glass {
  background: var(--glass-bg);
  -webkit-backdrop-filter: var(--glass-blur);
  backdrop-filter: var(--glass-blur);
  border: 1px solid var(--glass-border);
  box-shadow: var(--glass-shadow);
}

/* ---- appbar ----
   Фирменная оранжевая плашка (владелец, 19.08.2026). Цвет — литерал, а НЕ
   var(--accent): accent перекрашивается в тёмной схеме (#CC4100 -> #FD622C),
   а плашка бренда обязана быть одной и той же всегда. Тот же урок, что уже
   записан выше про --selected-bg. Всё содержимое на ней белое, включая
   треугольник логотипа. Стекло убрано: панель непрозрачная и лежит НАД
   скроллером — размывать под ней нечего, blur был бы чистой платой за кадры. */
/* Градиент вместо плашки (владелец, 19.08.2026): светлый -> тёмный слева
   направо, ось наклонена на 35° от горизонтали. В CSS 90deg = «вправо», так
   что наклон вниз-вправо на 35° = 125deg. Литералами, а не var(--accent*):
   в тёмной схеме переменные перекрашиваются, а шапка должна быть одна.

   Диапазон растянут по просьбе владельца — но ТОЛЬКО в тёмную сторону.
   Светлый конец остался ровно брендовым #FD622C, потому что он приходится
   на левый край, где стоит логотип: белый текст на #FD622C даёт контраст
   3.0:1, это ровно порог для крупного жирного шрифта. Осветлить начало
   на пару шагов — и логотип уходит под порог. Тёмный конец увести можно
   свободно: справа только часы и кнопка ↓, им контраст лишь прибавился
   (4.9:1 -> 8.7:1). Перепад яркости при этом вырос почти вдвое. */
/* Высота удвоена с 72px до 144px (владелец, 19.08.2026), текст и иконки
   отмасштабированы тем же множителем — иначе в двойной плашке плавало бы
   прежнее мелкое содержимое. Арифметика высоты: 36 сверху + 72 кнопка +
   36 снизу = 144, ровно вдвое. */
/* Шапка живёт ВНУТРИ скроллера, сразу под заставкой, и липнет к его верху.
   Пока гость на заставке, её просто не видно — она ниже сгиба, и заставка
   остаётся во весь экран. Как только лента поехала вниз, шапка приходит
   первой и дальше стоит, а под ней пришпиливаются полосы категорий. */
.appbar {
  display: flex; align-items: center; gap: 20px;
  padding: 36px 32px;
  background: linear-gradient(125deg, #FD622C 0%, #8A2B00 100%);
  color: #fff;
  flex-shrink: 0;
  flex: 0 0 auto; position: relative; z-index: 7;
}
.brand {
  font-weight: 800; font-size: 34px; letter-spacing: 0.1em; text-transform: uppercase;
  display: flex; align-items: center; gap: 14px;
}
.brand-tri {
  width: 0; height: 0; flex-shrink: 0;
  border-left: 7px solid transparent; border-right: 7px solid transparent;
  border-bottom: 11px solid var(--accent);
}
/* Размер треугольника поднят только в шапке: этот же класс носит логотип на
   заставке, и там свой масштаб. */
.appbar .brand-tri {
  border-bottom-color: #fff;
  border-left-width: 13px; border-right-width: 13px; border-bottom-width: 20px;
}
.brand-sub {
  font-size: 18px; font-weight: 700; letter-spacing: 0.16em; color: var(--hint); text-transform: uppercase;
  /* По центру строки, вровень с KANTIN (владелец, 09.10.2026): прижатая к низу
     подпись читалась как съехавшая на второй ряд. */
  margin-left: 8px; align-self: center;
}
.appbar .spacer { flex: 1; }

/* Выбор языка в шапке. Две кнопки в одной капсуле, выбранная залита белым —
   тот же приём, что у «Здесь / С собой» на экране имени, чтобы гость узнавал
   элемент. Цель нажатия 64px по высоте: это киоск, палец не мышь. */
.lang-switch {
  display: flex; align-items: center;
  background: rgba(255,255,255,0.16);
  border: 1px solid rgba(255,255,255,0.32);
  border-radius: 999px;
  padding: 4px;
}
.lang-switch button {
  min-width: 76px; height: 56px; padding: 0 14px;
  border: 0; border-radius: 999px; background: none;
  color: rgba(255,255,255,0.8);
  font-size: 21px; font-weight: 800; letter-spacing: 0.04em;
  cursor: pointer;
  transition: background-color 160ms ease, color 160ms ease;
}
.lang-switch button.on {
  /* Литералы, а не --card/--text: те переворачиваются в тёмной схеме, на
     которой уже обжигались — белая плашка печатала по себе белым. */
  background: #FFFFFF; color: #111111;
}

.clock { font-size: 26px; font-weight: 700; color: var(--hint); }
.appbar .brand-sub, .appbar .clock { color: rgba(255,255,255,0.78); }
.back-to-attract {
  width: 72px; height: 72px; border-radius: 50%;
  border: 1px solid rgba(255,255,255,0.45);
  background: rgba(255,255,255,0.14); color: #fff; font-size: 30px; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}

/* ---- layout: categories + grid ----
   Portrait-first (base): categories as a horizontal scroll strip above a
   2-column product grid, matching a vertically-mounted kiosk tablet.
   Portrait is the only supported orientation (owner, 13.08.2026) — the kiosk
   iPad is mounted vertically, so there is no landscape branch to keep in sync. */
/* The single scroller. Everything below the appbar lives inside it. */
/* Прокручивается ТОЛЬКО поле с товарами. Шапка и полосы категорий лежат выше
   в панели и в прокрутке не участвуют — поэтому отдача при оттяге двигает
   одни карточки, а не весь экран (владелец, 25.08.2026). */

/* Экран меню целиком: шапка + полосы категорий + прокручиваемое поле товаров.
   Это он ЕДЕТ при переходе на заставку и он же гасится по прозрачности.
   Меню НАЕЗЖАЕТ на заставку (та при параллаксе отстаёт), поэтому нужен
   z-index выше неё — иначе позиционированная заставка рисуется поверх — и
   собственный фон, без него она просвечивала бы между плитками. */
/* Мягкая граница перехода (владелец, 25.08.2026, по референсу: верх экрана уже
   полностью новый слой, низ ещё старый, между ними размытая полоса).
   Маска задана РАЗ и не пересобирается: она вдвое выше коробки, внутри готовая
   растяжка от прозрачного к плотному. Двигаем только положение — как фон.
   Арифметика, на которой я обжёгся в прошлый заход: проценты в mask-position
   считаются от РАЗНИЦЫ размеров маски и коробки, то есть от одной её высоты.
   Рабочий диапазон 0%..100%, а не 0..-200%, как я взял тогда — маска уезжала
   за пределы и не двигалась вовсе.
   pointer-events здесь НЕТ намеренно. Ставил — и получил хуже, чем лечил:
   маска живёт до конца перехода, то есть больше секунды, и всё это время меню
   не принимало нажатий. Гость входил в меню и секунду не мог ничего нажать
   (владелец, 25.08.2026: «аномалии при использовании меню»). Прошлая поломка
   касаний была не от маски как таковой, а от её неверного положения: она
   уезжала за пределы, элемент оказывался прозрачным целиком, и попадания шли
   мимо видимого. С верным диапазоном 0..100% этого не происходит. */


/* Both nav rows pin as one block at the top of the scroller. Before the banner
   was removed this was a two-state thing: a transparent gradient-blur bar
   floating over the video, collapsing into a solid bar (body.nav-stuck) once
   the video scrolled past. No video left, so only the solid state remains —
   the whole over-video branch (negative margin, gradient, backdrop-blur, chip
   text-shadows, the nav-stuck toggle in app.js) is deleted, not disabled. */
.nav-stack {
  flex: 0 0 auto; position: relative; z-index: 6;
  /* БЕЗ собственного фона. Сплошная заливка тут перекрывала градиент кромки
     панели, и в покое обе строки сидели на белом прямоугольнике вместо
     растворяющегося края (владелец, 25.08.2026). Фон даёт .panel-bg.
     Тень тоже убрана: в покое она рисовала линию поперёк фотографии. */
}
/* В тёмной теме тень тут была ещё заметнее — та же линия поперёк фотографии,
   только жирнее. Убрана вместе со светлой (владелец, 25.08.2026). */

.categories {
  flex: 0 0 auto;
  position: relative; z-index: 2;
  padding: 10px 12px 16px;
  display: flex; flex-direction: row; gap: 8px;
  overflow-x: auto; overflow-y: hidden;
  background: transparent;
  border-bottom: none;
}
/* Wolt-style reference: categories read as text first, chip-shape second —
   active = accent-orange bold text + underline (not a filled pill), inactive
   = plain translucent-white text chip. Kept as a real chip shape (not bare
   text) for the touch target; the "lightness" comes from the low alpha. */
.cat-btn {
  border: 1.5px solid transparent;
  background: rgba(255,255,255,0.14); color: var(--text);
  text-align: left; padding: 15px 22px; border-radius: 20px;
  font-size: 18px; font-weight: 600; cursor: pointer;
  min-height: 60px; flex: 0 0 auto; white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
  .cat-btn {
    background: rgba(20,20,20,0.22); border-color: transparent; color: var(--text);
  }
}
/* Owner correction (iPad photo feedback): active tab was reading as a thick
   glow/underline (box-shadow) — replaced with a plain thin border outline
   around the whole chip instead. */
/* Цвет — тот же --accent, что у «Поиска» и «ТОП» рядом (владелец, 09.10.2026:
   «почему цвет разный?»). Светлый --accent-2 к тому же давал оранжевому тексту
   на светлом фоне контраст ниже 4.5:1. */
/* Рамки убраны (владелец, 09.10.2026): выбранную категорию отличает мягкая
   оранжевая подложка и оранжевый текст. border остаётся прозрачным, чтобы
   чип не менял размер при выборе. */
.cat-btn.active {
  background: var(--accent-weak); border: 1.5px solid transparent;
  color: var(--accent); font-weight: 800;
  box-shadow: none;
}
@media (prefers-color-scheme: dark) {
  .cat-btn.active { background: var(--accent-weak); border-color: transparent; }
}

/* Поиск и «ТОП» — слева в ПЕРВОЙ строке навигации, рядом с разделами
   (владелец, 22.09.2026). Стоят ВНЕ прокручиваемой ленты .buckets: она паркует
   активный раздел по центру и гасит края маской, внутри неё чипы уезжали бы
   из виду. Лента ужимается на их ширину и центрует уже внутри остатка. */
.nav-row { display: flex; align-items: center; gap: 12px; padding-left: 16px; }
.nav-row .buckets { flex: 1 1 auto; min-width: 0; }
.nav-pins { flex: 0 0 auto; display: flex; gap: 10px; }
.cat-pin {
  flex: 0 0 auto; min-height: 60px; cursor: pointer;
  border-radius: 20px; padding: 15px 20px;
  font-size: 18px; font-weight: 800; white-space: nowrap;
  display: flex; align-items: center; justify-content: center;
}
/* Лупа с подписью: одна иконка на светлой шапке читается плохо, а рамка у
   чипов здесь почти невидима по дизайну (см. .cat-btn). */
.cat-pin-search {
  gap: 9px; padding: 15px 20px 15px 18px;
  border: 1.5px solid transparent; background: var(--accent-weak); color: var(--accent);
}
.cat-pin-search svg { width: 24px; height: 24px; flex: 0 0 auto; }
/* «ТОП» залит фирменным оранжевым — просил владелец, и он же отличает подборку
   от обычных якорей раздела. */
.cat-pin-top { border: none; background: var(--accent); color: #fff; letter-spacing: 0.06em; }
@media (prefers-color-scheme: dark) {
  .cat-pin-search { background: var(--accent-weak); border-color: transparent; }
}

/* ---- экран подборки (поиск / ТОП) ---- */
/* z-index 15: НИЖЕ карточки товара (30) и панели заказа (20) намеренно —
   из подборки гость уходит в позицию и всё время видит сумму заказа. */
.find-screen {
  position: fixed; inset: 0; z-index: 15;
  background: var(--bg);
  display: flex; flex-direction: column;
}
.find-head {
  flex: 0 0 auto; display: flex; align-items: center; gap: 12px;
  padding: calc(14px + env(safe-area-inset-top)) 16px 12px;
  background: var(--bg);
}
.find-head .modal-close { position: static; flex: 0 0 auto; }
.find-title { font-size: 26px; font-weight: 800; color: var(--accent); letter-spacing: 0.06em; }
.find-input {
  flex: 1 1 auto; min-width: 0;
  border: 1px solid var(--surface-2); border-radius: 18px;
  background: var(--card); color: var(--text);
  padding: 16px 18px; font-size: 20px; font-weight: 600;
}
/* Нижний отступ как у .menu: под панелью заказа и клавиатурой должен
   оставаться домотанный последний ряд. */
.find-results { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px 16px 420px; }
.find-empty { position: absolute; left: 0; right: 0; top: 40%; text-align: center; color: var(--hint); font-size: 18px; }

/* .menu is a flex:1 child of .layout (flex column) — same min-height:auto
   trap as .modal-body; without min-height:0 it can refuse to shrink below
   its content height and overflow-y:auto never activates. */
/* No longer its own scroller — .page-scroll owns scrolling now, so the
   menu simply grows to its content height and the banner scrolls off with it. */
/* Нижний отступ = высота панели заказа (~150px) + воздух, иначе последний ряд
   карточек прячется под ней и до него не домотать. */
.menu { padding: 16px 16px 200px; background: var(--bg); }
.loading, .error { padding: 60px 0; text-align: center; color: var(--hint); font-size: 15px; }
/* Слой поверх витрины: сообщение о неудачной загрузке меню больше не заменяет
   собой страницу, поэтому ему нужен собственный фон и место на экране. */
.menu-error {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 90;
  padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
  background: var(--bg); border-top: 1px solid rgba(0,0,0,.08);
}

/* Section headers are plain text, no background plate (Wolt-style ref). */
/* Крупнее прежнего: киоск стоит вертикально, и на него смотрят стоя, а не с
   расстояния вытянутой руки. Шапка и бровка уже были отмасштабированы под
   этот экран, а меню под ними оставалось «телефонного» кегля. */
.category-title { font-size: 28px; font-weight: 800; color: var(--text); margin-bottom: 18px; }

.product-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 14px;
}
/* iPad portrait (the kiosk's actual mount orientation) is wide enough for 3
   columns instead of 2 — owner request. Narrower phone-width fallback keeps
   2 columns. */
@media (min-width: 700px) {
  .product-grid { grid-template-columns: repeat(3, 1fr); }
}

/* Plain white cards, no border/blur — just a soft tint shadow, matching the
   Wolt-style reference (owner: "белые карточки БЕЗ теней-рамок, мягкая
   tint-тень"). Glass/blur is reserved for panels that float over video. */
/* content-visibility: auto ЗДЕСЬ НЕ РАБОТАЕТ — проверено замером 18.09.2026.
   Гипотеза была: 105 фотографий держатся распакованными разом, Safari их
   выбрасывает и распаковывает заново, отсюда рывки. Поставил
   content-visibility: auto с contain-intrinsic-size — трасса отрисовки стала
   ХУЖЕ: растр 90мс -> 190мс, распаковок 41 -> 131. Плитка, выезжая за край и
   возвращаясь, рисуется заново каждый раз, и это дороже, чем держать её
   готовой. Высота ленты при этом не поехала (7515 -> 7512), то есть дело не в
   раскладке. НЕ ВОЗВРАЩАТЬ без нового замера. */
.product-card {
  position: relative;
  background: var(--card);
  border-radius: 20px;
  overflow: hidden;
  cursor: pointer;
  box-shadow: var(--card-shadow);
  display: flex; flex-direction: column;
  min-height: 220px;
  /* Карточка — замкнутый мир: снаружи в неё ничего не заглядывает, наружу
     ничего не торчит (overflow:hidden и так режет). Контейнмент позволяет
     движку не перекладывать сотню карточек, когда меняется что-то одно —
     бейдж количества, плашка стоп-листа. Размер сюда не входит намеренно:
     высоту ряда сетки задаёт содержимое. */
  contain: layout paint;
}
.product-card:active { transform: scale(0.98); }

.product-card-image {
  width: 100%; aspect-ratio: 1.2 / 1; background: var(--surface-2);
  display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.product-card-image img { width: 100%; height: 100%; object-fit: cover; }

.product-placeholder {
  width: 100%; height: 100%;
  display: flex; align-items: center; justify-content: center;
  background: var(--gradient); opacity: 0.85;
}
.product-placeholder span {
  font-weight: 800; letter-spacing: 0.14em; color: #fff; font-size: 12px;
}

.product-card-body { padding: 14px 16px 18px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.product-card-name { font-weight: 600; font-size: 20px; line-height: 1.25; color: var(--text); }
/* Price + chevron on their own row under the name (owner spec) — price is
   plain text, not accent-colored; accent is reserved for CTAs/active state. */
.product-card-price-row {
  display: flex; align-items: center; justify-content: space-between; gap: 6px;
  margin-top: auto;
}
.product-card-price { font-weight: 700; font-size: 19px; color: var(--text); }
/* Brand triangle indicator (same clip-path + gradient technique as
   .attract-hint-arrow, the swipe-up-to-menu triangle on the home screen) —
   re-oriented to point right and scaled down, for brand consistency instead
   of a generic chevron glyph, per owner request. */
.product-card-chevron {
  width: 7px; height: 11px; flex-shrink: 0;
  background: var(--gradient);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}

/* Кнопка «−» прямо на плитке, под счётчиком: убрать лишнее, не открывая
   карточку товара (владелец, 25.08.2026). Появляется вместе со счётчиком и
   исчезает вместе с ним — рисует их refreshCardBadges.
   Размер 44px — минимум, по которому уверенно попадают пальцем; счётчик выше
   намеренно мельче, он читается, а не нажимается. */
.card-minus {
  /* Размер один в один со счётчиком над ней — обоим его задаёт --card-badge.
     Отступ сверху считается от него же: счётчик занимает 10px..10+размер,
     плюс ровный зазор в 6px. Поэтому при следующем изменении размера ничего
     подгонять руками не придётся. */
  position: absolute; right: 10px; z-index: 3;
  top: calc(10px + var(--card-badge) + 6px);
  width: var(--card-badge); height: var(--card-badge);
  border-radius: calc(var(--card-badge) / 2);
  border: none;
  background: rgba(20, 20, 20, 0.62);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  color: #fff; font-size: 24px; font-weight: 800; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; padding: 0;
  box-shadow: var(--shadow);
}
.card-minus:active { transform: scale(0.9); }

.product-badge {
  position: absolute; top: 10px; right: 10px; z-index: 2;
  background: var(--accent); color: #fff; font-weight: 800; font-size: 20px;
  min-width: var(--card-badge); height: var(--card-badge);
  border-radius: calc(var(--card-badge) / 2);
  display: flex; align-items: center; justify-content: center;
  box-shadow: var(--shadow);
}

/* ---- панель заказа внизу экрана ----
   Пришла на смену круглой плавающей кнопке корзины (владелец, 19.08.2026).
   Во всю ширину, чтобы состав заказа и сумма читались с расстояния, с которого
   человек стоит у киоска. */
/* Оттяг глушим ТОЛЬКО пока палец ведёт возврат домой. Класс вешает app.js на
   touchstart у верха меню и снимает на отпускании.
   Зачем: contain запрещает прокрутке перетекать к родителю, но собственный
   оттяг элемента оставляет — у верха меню Safari оттягивал содержимое ленты,
   обнажая фон над шапкой, и поверх этого наш жест двигал ленту целиком. Две
   подвижки складывались в белое поле между заставкой и шапкой. none убирает
   сам оттяг; на всё остальное время отдача возвращается. */

/* На начальном экране панель заказа не показываем: заставка теперь лежит НИЖЕ
   неё по слоям и сама перекрыть не может, а гостю на главной сумма чужого
   недооформленного заказа не нужна. */
body.at-home .cart-bar { display: none; }

.cart-bar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
  display: flex; align-items: center; gap: 20px;
  padding: 20px 28px calc(20px + env(safe-area-inset-bottom, 0px));
  background: var(--card);
  border-top: 1px solid var(--border);
  box-shadow: 0 -8px 28px rgba(20,24,38,0.10);
  cursor: pointer;
}
@media (prefers-color-scheme: dark) {
  .cart-bar { box-shadow: 0 -8px 28px rgba(0,0,0,0.45); }
}
.cart-bar-info { flex: 1; min-width: 0; }
/* Состав заказа в одну строку: длинный список честнее подрезать многоточием,
   чем переносить и раздувать панель на пол-экрана. */
.cart-bar-line {
  font-size: 15px; font-weight: 600; color: var(--hint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cart-bar-total { font-size: 30px; font-weight: 800; color: var(--text); margin-top: 4px; }
/* Круглая кнопка с корзиной вместо надписи «Далее» (владелец, 25.08.2026).
   Размер держим прежним по высоте — 68px это та же площадь под палец, что и у
   старой таблетки; на киоске мельчить нельзя. */
.cart-bar-next {
  flex: 0 0 auto;
  position: relative;
  display: flex; align-items: center; justify-content: center;
  width: 68px; height: 68px;
  padding: 0; border: none; border-radius: 999px;
  background: var(--gradient); color: #fff;
  font-family: inherit;
  cursor: pointer;
}
/* Колёса тележки утяжеляют низ, и по геометрическому центру иконка читается
   съехавшей вниз — сдвигаем на пиксель вверх, к оптическому. */
.cart-bar-cart { width: 32px; height: 32px; flex-shrink: 0; transform: translateY(-1px); }
/* Счётчик — белый кружок на углу иконки. Число позиций, а не строк заказа. */
.cart-bar-count {
  position: absolute; top: -2px; right: -2px;
  min-width: 30px; height: 30px; padding: 0 7px;
  box-sizing: border-box;
  display: flex; align-items: center; justify-content: center;
  border-radius: 999px;
  background: #fff; color: var(--accent, #E2551C);
  font-size: 17px; font-weight: 800; line-height: 1;
  box-shadow: 0 1px 4px rgba(20,24,38,0.28);
}
.cart-bar-next:active { transform: scale(0.97); filter: brightness(0.95); }

.cart-bar.hidden { display: none; }

/* ---- modal (product detail): fullscreen photo background, bottom liquid-glass
   sheet sliding up over it. Owner spec: "открывается целое окно ... фото ...
   снизу допы с liquid glass". Open/close animates via the .open class (added
   one frame after unhiding, removed before re-hiding) rather than a hard cut. */
.modal {
  position: fixed; inset: 0; z-index: 30;
  height: var(--app-h, 100dvh);
  opacity: 0;
  transition: opacity 320ms ease;
}
.modal.hidden { display: none; }
.modal.open { opacity: 1; }

/* Открытие карточки. Раньше это был голый кроссфейд, и владелец читал его как
   «просто открылась новая страница» (17.09.2026). Теперь фотография доезжает
   с 1.06 до своего размера, а нижний лист поднимается из-под края — движение
   идёт ВНУТРИ карточки, потому что transform самой .modal занят свайпом
   (setProductOffset) и вешать туда ещё и открытие нельзя.
   Кривая (0.22,1,0.36,1) — быстрый старт с долгим торможением: карточка
   «прилетает и садится», без пружинного перелёта, который на фото напитка
   читался бы как дрожь. Анимируются только transform и opacity — оба идут
   в композиторе, слой фотографии на iPad не перерисовывается. */
.modal-bg-video, .modal-bg-image, .modal-card {
  transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease;
}
.modal:not(.open) .modal-bg-video,
.modal:not(.open) .modal-bg-image { transform: scale(1.06); }
.modal:not(.open) .modal-card { transform: translateY(28px); opacity: 0; }
.modal.open .modal-bg-video,
.modal.open .modal-bg-image { transform: scale(1); }
.modal.open .modal-card { transform: translateY(0); opacity: 1; }

/* Пока карточку тащат пальцем, лист и фото должны ехать ВМЕСТЕ с ней жёстко:
   собственный доводочный переход тут превращается в отставание содержимого
   от рамки. */
.modal.swiping .modal-bg-video,
.modal.swiping .modal-bg-image,
.modal.swiping .modal-card { transition: none; }

@media (prefers-reduced-motion: reduce) {
  .modal-bg-video, .modal-bg-image, .modal-card { transition: opacity 160ms ease; }
  .modal:not(.open) .modal-bg-video,
  .modal:not(.open) .modal-bg-image,
  .modal:not(.open) .modal-card { transform: none; }
}

/* Сдвинутая пальцем карточка скругляется и отрывается тенью от меню под ней
   (владелец, 24.08.2026): так жест читается как «отбрасываю карту», а не как
   разъезжающийся экран. Класс вешает setProductOffset с первого движения и
   держит до конца полёта в плитку.
   Радиус дублируется на .modal-bg намеренно: Safari не обрезает по
   border-radius содержимое элемента, к которому применён transform, — на
   родителе одном фотография вылезала бы прямыми углами. */
.modal.swiping,
.modal.swiping .modal-bg {
  border-radius: 28px;
  overflow: hidden;
}
.modal.swiping { box-shadow: 0 30px 60px rgba(0, 0, 0, 0.45); }

/* Пока карточку тащат пальцем или она летит в плитку, выключаем backdrop-filter
   у всего стеклянного, что едет вместе с ней: размытие пересэмплируется каждый
   кадр движения, и несколько таких слоёв разом — главная цена этого жеста на
   iPad. Полупрозрачные подложки у элементов остаются, поэтому в движении они
   выглядят просто плоским стеклом, а морозная фактура возвращается в покое —
   глаз на летящей карточке подмены не ловит.
   ponytail: если фактуру в движении всё же захочется — путь один, дешевле нет:
   прекомпозить блюр в фон элементов картинкой, а не считать его живьём. */
.modal.swiping .modal-close,
.modal.swiping #product-close::before,
.modal.swiping .product-nutrition,
.modal.swiping .modifier-option,
.modal.swiping .modal-footer,
.modal.swiping .size-chip,
.modal.swiping .modifier-tile {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

/* Layer 1: fullscreen product video/photo page. inset:0 on a fixed-position
   ancestor (.modal) ties it to the viewport edges directly (no vh gap bugs),
   and object-fit:cover on the video/img below always fills it edge to edge —
   this is the "page", not a modal photo; .modal-card (layer 2) is a bottom
   sheet that overlays on top of it. */
.modal-bg {
  position: absolute; inset: 0; z-index: 1;
  width: 100%; height: 100%;
  overflow: hidden;
  background: var(--gradient);
  /* Свайпы по фото разбираем сами (см. app.js). Прокручивать здесь нечего,
     поэтому браузеру жест отдавать не надо — иначе Safari успевает начать
     свой оттяг страницы и палец «залипает». */
  touch-action: none;
}
.modal-bg-video, .modal-bg-image { position: absolute; inset: 0; width: 100%; height: 100%; }
.modal-bg-video { object-fit: cover; }
/* Фото товара — обычный <img>, и протаскивание его пальцем/мышью запускает
   нативный drag картинки: браузер тут же шлёт pointercancel, и свайп умирает,
   не начавшись (поймано в Chromium — pointerdown, следом cancel в координатах
   0,0). Запрещаем перетаскивание и выделение. */
.modal-bg img, .modal-bg-image, .modal-bg-video {
  -webkit-user-drag: none;
  user-select: none; -webkit-user-select: none;
}
.modal-bg-video.hidden { display: none; }
.modal-bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
.modal-bg .product-placeholder span { font-size: 20px; }
.modal-bg::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(to bottom, rgba(0,0,0,0.32) 0%, rgba(0,0,0,0.04) 26%, rgba(0,0,0,0.10) 55%, rgba(0,0,0,0.6) 100%);
  pointer-events: none;
}

/* Base circular glass button — reused by the cart sheet's close button
   (.sheet-header .modal-close overrides position/bg/color for that light
   context further down). */
.modal-close {
  position: absolute; top: calc(16px + env(safe-area-inset-top)); right: 16px; z-index: 32;
  width: 46px; height: 46px; border-radius: 50%; border: 1px solid var(--glass-border);
  background: rgba(20,20,20,0.4);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  backdrop-filter: blur(14px) saturate(140%);
  color: #fff; font-size: 18px; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
/* Product page: back button, top-left over the video, not top-right close. */
#product-close {
  left: 16px; right: auto;
  border-color: rgba(255,255,255,0.25);
  font-size: 22px; font-weight: 700;
}
/* Назад на карточке — фирменный треугольник KANTIN, повёрнутый влево
   (владелец, 01.10.2026). 09.10.2026 владелец: «слишком яркий, бросается в
   глаза» — треугольник стал белым и меньше, и лежит в том же стеклянном круге,
   что плашка КБЖУ: кнопка видна на любом фото, но не перекрикивает его.
   Круг 52px, зона нажатия вокруг — 72px. */
#product-close {
  top: calc(50px + env(safe-area-inset-top));
  width: 72px; height: 72px; border: none;
  background: none;
  -webkit-backdrop-filter: none; backdrop-filter: none;
  box-shadow: none;
  transition: transform .15s ease;
}
#product-close::before {
  content: ""; position: absolute; inset: 10px; border-radius: 50%;
  background: rgba(20,24,38,0.28);
  -webkit-backdrop-filter: blur(18px) saturate(150%);
  backdrop-filter: blur(18px) saturate(150%);
  border: 1px solid rgba(255,255,255,0.22);
}
#product-close:active { transform: scale(0.92); }
.back-tri {
  position: relative; /* над стеклянным кругом */
  width: 0; height: 0;
  border-top: 9px solid transparent; border-bottom: 9px solid transparent;
  border-right: 14px solid rgba(255,255,255,0.92);
  margin-left: -3px; /* оптический центр треугольника левее геометрического */
}

/* Layer 2: floating options over the fullscreen video (layer 1, .modal-bg).
   No white sheet, no drag-to-expand — name/modifiers float directly over the
   video as glass chips, with a pinned full-width bar (weight + stepper + CTA)
   at the very bottom. Height is content-driven with a cap + internal scroll
   for products with several modifier groups. */
.modal-card {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 31;
  max-height: 78vh;
  display: flex; flex-direction: column;
}

/* min-height:0 on a flex:1 child is required so overflow-y:auto can actually
   kick in below max-height instead of the card just growing past it
   (classic flexbox min-height:auto trap). */
.modal-body {
  flex: 1; min-height: 0;
  padding: 18px 16px 10px; overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
/* Product header floats over the photo, top-aligned under the back button —
   name, live nutrition, description toggle. Layer 2 (.modal-card) owns the
   bottom of the screen; this owns the top. */
.product-top {
  position: absolute; z-index: 31;
  top: calc(72px + env(safe-area-inset-top)); left: 0; right: 0;
  padding: 0 16px; text-align: center;
  pointer-events: none;
}
.product-top > * { pointer-events: auto; }
/* Тёмной подложки-градиента здесь больше нет (владелец, 20.08.2026): она
   выручала белый текст, но пачкала светлые рендеры серой полосой. Контраст
   теперь держит оранжевая плашка .product-head под названием и КБЖУ. */
/* Montserrat и тонкое начертание ОТКАЧЕНЫ (владелец, 20.08.2026): шрифт
   вернулся к общему стеку киоска и прежним весам. Новые отступы — воздух
   перед КБЖУ и вдвое меньший просвет между ними — оставлены как есть. */
/* Плашка осталась ТОЛЬКО под КБЖУ (владелец, 20.08.2026), название вынесено
   на фотографию. Сам .product-head — просто вертикальная раскладка. */
.product-head {
  display: inline-flex; flex-direction: column; align-items: center; gap: 16px;
}
.product-top h2 {
  /* В полтора раза крупнее прежних 20px (владелец, 20.08.2026). */
  font-size: 30px; font-weight: 800; margin: 0; color: #fff;
  /* Тень под буквами убрана (владелец): она нужна была поверх фотографии, а на
     сплошной заливке только мылит текст. */
  text-shadow: none;
}
.product-weight { font-size: 12px; color: #fff; font-weight: 700; white-space: nowrap; }
/* БЖУ row — 4 columns, only rendered when item.nutrition is non-null (see
   renderProductNutrition in app.js / _build_nutrition in backend/main.py). */
/* Колонки больше НЕ растягиваются на всю ширину (владелец, 20.08.2026:
   «сократи расстояние между КБЖУ в два раза»). Раньше стоял flex:1, каждая
   колонка занимала четверть экрана, и текст плавал в её середине: при
   заявленном gap:10px реальный просвет между числами был ~165px. Теперь ряд
   центрируется, колонки по содержимому, и gap — это ровно тот просвет,
   который видно. 82px = половина прежних 165.
   margin-top 24px вместо 6px — тот самый воздух между названием и КБЖУ. */
/* Liquid glass вместо оранжевой плашки (владелец, 22.08.2026). Тинт НЕ нулевой:
   рендеры бывают и светлые (панини, лимонады), а чистое стекло белым текстом на
   светлом фото уходит ниже 4.5:1. rgba(20,24,38,0.28) + blur держит контраст на
   любом герое, text-shadow — страховка для планшетов без backdrop-filter. */
.product-nutrition {
  display: flex; justify-content: center; gap: 82px; margin: 0;
  background: rgba(20,24,38,0.28);
  backdrop-filter: blur(18px) saturate(150%);
  -webkit-backdrop-filter: blur(18px) saturate(150%);
  border: 1px solid rgba(255,255,255,0.22);
  border-radius: 20px;
  padding: 14px 26px 15px;
  box-shadow: 0 6px 20px rgba(20,24,38,0.18), inset 0 1px 0 rgba(255,255,255,0.28);
}
.product-nutrition { flex-direction: column; align-items: center; gap: 0; }
.nutrition-row { display: flex; justify-content: center; gap: 82px; }
.nutrition-col { flex: 0 0 auto; text-align: center; }
/* Состав — вторая строка той же стеклянной плашки, под волосяной линией
   (владелец, 09.10.2026). width:0 + min-width:100% — текст переносится по
   ширине ряда КБЖУ и НЕ раздувает плашку, даже у сэндвича с двадцатью
   ингредиентами. */
.product-composition {
  width: 0; min-width: 100%;
  margin: 12px 0 0; padding-top: 11px;
  border-top: 1px solid rgba(255,255,255,0.22);
  text-align: center; color: rgba(255,255,255,0.92);
  font-size: 12px; font-weight: 500; line-height: 1.45;
  text-shadow: 0 1px 3px rgba(0,0,0,0.45);
}
/* Без ряда КБЖУ состав — единственная строка плашки: без линии и отступа
   сверху, а ширину задаёт сам текст (до 420px), иначе плашка схлопнется в 0. */
.product-composition:first-child {
  width: auto; min-width: 0; max-width: 420px;
  margin: 0; padding-top: 0; border-top: 0;
}
.composition-label {
  font-size: 10px; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.06em; color: rgba(255,255,255,0.7); margin-right: 4px;
}
.nutrition-value {
  display: block; color: #fff; font-weight: 800; font-size: 13px;
  text-shadow: 0 1px 3px rgba(0,0,0,0.45);
}
.nutrition-label {
  display: block; color: rgba(255,255,255,0.88); font-size: 10px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.03em; margin-top: 2px;
  text-shadow: 0 1px 3px rgba(0,0,0,0.45);
}
/* "Подробнее" pill — only shown when the item has a real description (see
   renderProductModal); toggles .product-desc open/closed, chevron flips. */
.product-details-toggle {
  display: flex; width: fit-content; align-items: center; gap: 4px; margin: 2px auto 8px;
  background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.26);
  color: #fff; font-size: 11.5px; font-weight: 700;
  border-radius: 999px; padding: 6px 14px; cursor: pointer;
}
.product-details-chevron { display: inline-block; transition: transform 220ms ease; }
.product-details-toggle.open .product-details-chevron { transform: rotate(180deg); }
.product-desc {
  font-size: 13px; color: rgba(255,255,255,0.88); line-height: 1.5; margin: 0 0 4px;
  text-shadow: 0 1px 6px rgba(0,0,0,0.5);
}
/* Base price is superseded by the CTA button's live total (base + mods) ×
   qty — kept updated in the DOM for consistency but not shown twice. */
.product-base-price { display: none; }
.hidden { display: none !important; }

.product-modifiers { display: flex; flex-direction: column; gap: 14px; margin: 10px 0 4px; }
.modifier-group-title {
  display: flex; align-items: center; gap: 8px;
  font-size: 12.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em;
  color: rgba(255,255,255,0.85); margin-bottom: 8px;
  text-shadow: 0 1px 6px rgba(0,0,0,0.5);
}
/* One price badge per group (owner request: price shown once next to the
   group name, not repeated on every tile) — only rendered when every option
   in that group genuinely shares the same price; see renderProductModifiers. */
.modifier-group-price {
  text-transform: none; letter-spacing: normal; font-weight: 700;
  font-size: 12px; color: #fff;
  background: rgba(255,255,255,0.18); border-radius: 999px; padding: 3px 9px;
}
.modifier-group-price-free { color: #fff; background: rgba(23,185,120,0.55); }

/* Floating glass chips, horizontal scroll per group — owner spec: "тёмное
   полупрозрачное стекло rgba(20,20,20,0.35-0.45) + blur", icon + name per
   chip, directly over the video (no card behind the row). */
.modifier-options {
  display: flex; flex-direction: row; gap: 8px;
  overflow-x: auto; overflow-y: hidden;
  padding-bottom: 2px;
  -webkit-overflow-scrolling: touch;
}
.modifier-option {
  position: relative;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
  background: rgba(20,20,20,0.38);
  -webkit-backdrop-filter: blur(18px);
  backdrop-filter: blur(18px);
  border: 1px solid rgba(255,255,255,0.18);
  border-radius: 18px; padding: 12px 10px 10px; cursor: pointer; text-align: center;
  width: 78px; flex: 0 0 auto;
}
.modifier-option.selected {
  border-color: var(--selected-bg); background: var(--selected-bg); color: var(--selected-fg);
}
.modifier-option.selected .modifier-option-name { color: var(--selected-fg); }
.modifier-option-input {
  position: absolute; top: 6px; right: 6px;
  width: 15px; height: 15px; accent-color: var(--accent-2); flex-shrink: 0; margin: 0;
}
/* Иконка добавки — белый знак Lucide в круглом значке с фирменным градиентом
   (владелец, 09.10.2026). Значок одинаков в любом состоянии плитки: на тёмном
   стекле и на белой выбранной плитке оранжевый круг читается одинаково, поэтому
   цвет знака при выборе больше не меняется. padding у svg — это и есть поле
   вокруг знака: 36px круг, 20px рисунок. */
.modifier-option-icon {
  width: 36px; height: 36px; padding: 8px; box-sizing: border-box; flex-shrink: 0;
  border-radius: 50%; color: #fff;
  background: linear-gradient(135deg, #FD622C 0%, #CC4100 100%);
  box-shadow: 0 2px 6px rgba(204,65,0,0.35);
}
.modifier-option-name {
  font-size: 11px; font-weight: 600; line-height: 1.2; color: #fff;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.modifier-option-price { font-size: 10.5px; font-weight: 700; color: rgba(255,255,255,0.85); }

/* Pinned full-width bar (owner spec): weight pill left, stepper + orange CTA
   right. Its own dark glass panel, distinct from the transparent chip rows
   above it. */
.modal-footer {
  flex: 0 0 auto;
  display: flex; align-items: center; gap: 10px;
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
  background: rgba(20,20,20,0.42);
  -webkit-backdrop-filter: blur(24px) saturate(140%);
  backdrop-filter: blur(24px) saturate(140%);
  border-top: 1px solid rgba(255,255,255,0.14);
  border-radius: 24px 24px 0 0;
}
.modal-footer .product-weight {
  background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.26);
  border-radius: 999px; padding: 8px 14px; margin: 0;
}
#product-add { margin-left: auto; }

.stepper { display: flex; align-items: center; gap: 12px; background: var(--glass-bg-soft); border-radius: 20px; padding: 7px; border: 1px solid var(--glass-border); }
.modal-footer .stepper { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.24); }
.stepper-btn {
  width: 44px; height: 44px; border-radius: 14px; border: none;
  background: var(--glass-bg-strong); color: var(--text); font-size: 21px; font-weight: 700; cursor: pointer;
  box-shadow: var(--shadow);
}
.modal-footer .stepper-btn { background: rgba(255,255,255,0.85); color: var(--text); box-shadow: none; }
.stepper-value { min-width: 28px; text-align: center; font-size: 15px; font-weight: 800; }
.modal-footer .stepper-value { color: #fff; }
.stepper-sm .stepper-btn { width: 36px; height: 36px; font-size: 17px; }

.btn-primary {
  border: none; border-radius: 20px; cursor: pointer;
  background: var(--gradient); color: #fff; font-weight: 800;
}
/* min-height stays 60px (touch target) — only text + inner padding shrink. */
/* Кегль поднят под вертикальный iPad (владелец, 20.08.2026): на этот экран
   смотрят стоя, а кнопка оформления — главное действие на странице. */
.btn-big { padding: 18px 24px; font-size: 21px; min-height: 72px; }
.btn-block { width: 100%; }
.btn-primary:disabled { opacity: 0.55; cursor: default; }

/* ---- cart sheet ----
   Wolt-style reference: solid light content background, plain white item
   cards with a soft tint shadow (no border/blur) — glass/blur stays reserved
   for panels that float over video, which the cart never does. */
/* Окно оформления заказа выезжает СВЕРХУ поверх размытого меню (владелец,
   19.08.2026). Раньше это был непрозрачный слой inset:0 — при нём размывать
   меню не имело смысла, его всё равно не видно. Теперь окно прижато к верху,
   ограничено по высоте и скруглено снизу, так что размытая подложка читается
   по краям и внизу — из неё и понятно, что заказ лёг поверх меню. */
.sheet {
  position: fixed; top: 0; left: 0; right: 0; z-index: 30;
  max-height: 94vh;
  background: var(--bg);
  border-radius: 0 0 32px 32px;
  padding: 22px 24px calc(22px + env(safe-area-inset-bottom));
  /* Лист — колонка с ФИКСИРОВАННЫМ низом. Раньше прокручивался он целиком, и
     каждая новая позиция выдавливала вниз всё, что под ней: при шести позициях
     «вместе вкуснее» обрезалось краем экрана, а кнопка оформления уезжала за
     него совсем (владелец, 18.09.2026). Теперь прокручивается только список
     позиций, а рейка, комментарий и кнопка всегда на своих местах. */
  display: flex; flex-direction: column;
  overflow: hidden;
  -webkit-overflow-scrolling: touch;
  box-shadow: 0 18px 48px rgba(20,24,38,0.22);
  transform: translateY(-100%);
  transition: transform 420ms cubic-bezier(0.65, 0, 0.35, 1);
}
.sheet.open { transform: none; }
.sheet.hidden { display: none; }
@media (prefers-color-scheme: dark) {
  .sheet { box-shadow: 0 18px 48px rgba(0,0,0,0.6); }
}

/* Затемнение под окном: без него светлая тема выглядит так, будто окно висит
   в воздухе, а не лежит поверх меню. */
.checkout-scrim {
  position: fixed; inset: 0; z-index: 29;
  background: rgba(20,24,38,0.28);
  opacity: 0; transition: opacity 420ms ease;
}
.checkout-scrim.hidden { display: none; }
.checkout-scrim.open { opacity: 1; }

/* Размываются три области меню поимённо, а НЕ контейнер #app: окно заказа
   лежит внутри того же #app, и фильтр на общем предке размывал бы вместе с
   меню само окно (проверено скриншотом — читать было нечего). Переносить узел
   в разметке ненадёжно: браузер всё равно чинит вложенность по-своему. */
/* .appbar убрана из списка: размытие
   накладывалось бы на неё дважды — сквозь неё было видно кашу вместо шапки. */
/* Размытие ставится СКАЧКОМ, без transition: анимированный blur(0→10px) на
   всю площадь экрана — это 420мс полноэкранного пересчёта размытия на каждом
   открытии и закрытии окна, самая дорогая анимация во всём киоске. Движение
   несут окно (выезд сверху) и затемнение (fade) — на их фоне мгновенная
   подмена фона на размытый глазом не читается.
   ponytail: вернуть плавный блюр — добавить сюда transition: filter 420ms,
   но тогда это снова полноэкранная покадровая фильтрация. */
body.checkout-open .panel,
body.checkout-open .cart-bar { filter: blur(10px); }
@media (prefers-reduced-motion: reduce) {
  .sheet, .checkout-scrim,
  .panel, .cart-bar { transition: none; }
}
.sheet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.sheet-header h2 { font-size: 26px; font-weight: 800; color: var(--text); }
/* «Очистить» — справа, рядом с крестиком; второй тап подтверждает. */
.cart-clear {
  margin-left: auto; margin-right: 14px;
  min-height: 46px; padding: 0 20px; border-radius: 999px;
  border: 1px solid var(--border); background: var(--card); color: var(--hint);
  font: inherit; font-size: 17px; font-weight: 700; cursor: pointer;
}
.cart-clear.armed { border-color: var(--danger); color: var(--danger); }
.cart-clear:active { transform: scale(0.97); }
.sheet-header .modal-close {
  position: static; border-color: var(--border);
  background: var(--card); color: var(--text); box-shadow: var(--card-shadow);
}

/* min-height:0 обязателен: без него flex-ребёнок не даёт себя сжать ниже
   содержимого, и overflow-y никогда не включится — список снова полез бы вниз. */
.cart-items {
  display: flex; flex-direction: column; gap: 12px; margin: 14px 0;
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
}
/* Позиций много — строка ужимается: фото меньше, отступы плотнее. Так в то же
   окно влезает больше, прежде чем понадобится прокрутка. */
.cart-items.dense .cart-item-row { padding: 10px 12px; }
.cart-items.dense .cart-item-image { width: 44px; height: 44px; border-radius: 11px; }
.cart-items.dense { gap: 8px; }
.cart-items.dense .cart-item-name { font-size: 16px; }
.cart-empty { text-align: center; color: var(--hint); font-size: 16px; padding: 36px 0; }

/* Grid layout so long product names wrap onto their own row(s) and can never
   be covered by the qty stepper, regardless of how many lines they take. */
.cart-item-row {
  display: grid;
  grid-template-columns: 60px 1fr;
  grid-template-areas:
    "image name"
    "image price"
    "image mods"
    "image footer";
  column-gap: 14px; row-gap: 4px;
  align-items: start;
  background: var(--card);
  border-radius: 20px;
  box-shadow: var(--card-shadow);
  padding: 14px 16px;
}
/* Вспышка только что добавленной строки. Анимируем box-shadow, а не фон: фон
   строки — var(--card), и подмена цвета оставляла бы её «залипшей», если
   анимация не доиграет. */
@keyframes cart-row-flash {
  from { box-shadow: 0 0 0 3px var(--accent); }
  to   { box-shadow: var(--card-shadow); }
}
.cart-item-row.just-added { animation: cart-row-flash 900ms ease-out; }
.cart-item-image { grid-area: image; width: 60px; height: 60px; border-radius: 14px; overflow: hidden; flex-shrink: 0; background: var(--surface-2); align-self: start; }
.cart-item-image img { width: 100%; height: 100%; object-fit: cover; }
.cart-item-image .product-placeholder span { font-size: 8px; }
.cart-item-name {
  grid-area: name;
  font-weight: 700; font-size: 19px; line-height: 1.3;
  white-space: normal; word-break: break-word; overflow-wrap: anywhere;
}
.cart-item-price { grid-area: price; font-size: 15px; color: var(--hint); }
.cart-item-mods { grid-area: mods; font-size: 14px; color: var(--hint); }
.cart-item-footer {
  grid-area: footer;
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  margin-top: 6px;
}
.cart-item-total { font-weight: 800; font-size: 19px; }

.checkout-form { display: flex; flex-direction: column; gap: 12px; margin: 18px 0; }
.checkout-form label { font-size: 15px; color: var(--hint); font-weight: 700; display: flex; flex-direction: column; gap: 7px; }
.checkout-form input {
  padding: 14px; border-radius: 16px; border: 1px solid var(--border);
  font-size: 16px; background: var(--card); color: var(--text);
  min-height: 52px;
}
/* Пометка «обязательно» у подписи поля и подсказка под ним — гость должен
   понимать требование ДО того, как упрётся в кнопку. */
.field-required {
  font-size: 12px; font-weight: 800; letter-spacing: 0.02em;
  color: var(--accent); text-transform: lowercase;
}
.field-hint { font-size: 14px; font-weight: 600; color: var(--danger); }
.field-hint.hidden { display: none; }
.checkout-form input.input-error { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(244,89,91,0.2); }

.pay-note { text-align: center; color: var(--hint); font-size: 15px; margin-top: 10px; }

/* Бонусная карта в корзине. Привязанная — белый фон и чёрный текст, как любое
   выбранное состояние киоска; литералы, а не --card/--text, чтобы и в тёмной
   схеме отметка читалась одинаково. */
.checkout-form input.bonus-on { background: #fff; color: var(--ink); border-color: var(--ink); font-weight: 800; }
.bonus-status { display: flex; flex-direction: column; gap: 10px; margin-top: -4px; }
.bonus-status p { margin: 0; font-size: 15px; font-weight: 700; color: var(--text); white-space: pre-line; }
.bonus-join { display: flex; align-items: center; gap: 14px; }
.bonus-join p { font-weight: 600; color: var(--hint); }
.bonus-qr { flex: none; width: 132px; aspect-ratio: 1; background: #fff; border-radius: 16px; padding: 8px; }
.bonus-qr svg { display: block; width: 100%; height: 100%; }

/* ---- confirmation screen ---- */
.fullscreen {
  position: fixed; inset: 0; z-index: 40;
  background: var(--bg);
  display: flex; align-items: center; justify-content: center;
}
.fullscreen.hidden { display: none; }
/* ---- экран «заказ принят» ----
   Последовательность (владелец, 20.08.2026): заливка -> заголовок -> треугольник
   -> благодарность. Каждый шаг отдельной анимацией со своей задержкой, чтобы
   порядок читался, а не всё выпрыгивало разом. Класс .play вешает app.js в
   момент показа — так анимации можно перезапустить на следующем заказе. */
.fullscreen#confirm-screen {
  background: transparent;
  display: block;
  overflow: hidden;
}

/* Шаг 1. Оранжевая заливка: круг из центра разрастается на весь экран.
   Радиус берётся с запасом (150vmax), чтобы накрыть углы на любом экране.
   Кривая подобрана ЗАМЕРОМ, а не на слух (владелец, 20.08.2026: «заливка
   забагалась и перестала работать»). Перед этим стояла easeOutExpo
   cubic-bezier(0.16,1,0.3,1) — она проходила 65% радиуса за первые 100мс,
   и глаз читал это как мгновенную заливку, будто анимации нет вовсе.
   Сейчас cubic-bezier(0.4,0,0.2,1): рост равномерный по всей длительности.
   Плюс лёгкое подпроявление в первые кадры, чтобы граница круга не
   возникала из ниоткуда чёткой линией. */
.confirm-flood {
  position: absolute; inset: 0;
  background: linear-gradient(125deg, #FD622C 0%, #CC4100 55%, #8A2B00 100%);
  clip-path: circle(0% at 50% 42%);
  opacity: 0;
}
/* Тайминги сжаты вчетверо (владелец, 07.09.2026: «окно очень долго грузится»).
   Сцена та же и в том же порядке, просто вся укладывается в ~1.2с вместо 3.5с —
   гость пришёл за номером заказа, а не за роликом. */
#confirm-screen.play .confirm-flood {
  animation: confirm-flood calc(500ms * var(--t)) cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes confirm-flood {
  0%   { clip-path: circle(0% at 50% 42%);      opacity: 0.55; }
  18%  { opacity: 1; }
  100% { clip-path: circle(150vmax at 50% 42%); opacity: 1; }
}

/* Шаг 2. Крупный белый заголовок. Появляется МЕДЛЕННО (1.1s) и с небольшим
   подъёмом — владелец просил «медленно большими белыми буквами». */
.confirm-title {
  position: absolute; left: 0; right: 0; top: 8vh;
  margin: 0; padding: 0 24px;
  text-align: center; color: #fff;
  font-size: clamp(40px, 9vw, 84px); font-weight: 800; line-height: 1.05;
  letter-spacing: 0.01em;
  opacity: 0; transform: translateY(28px);
}
#confirm-screen.play .confirm-title {
  animation: confirm-rise calc(450ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(250ms * var(--t)) forwards;
}

/* Номер заказа — то, ради чего гость и смотрит на этот экран. Держим сразу под
   заголовком, крупно; если номера нет (старый counter-режим без движка), блок
   скрыт целиком — обрывок технического id тут уже показывали, звать по нему
   гостя невозможно. */
/* 08.10.2026: номер стоял на 34vh белым, а белый треугольник выезжал поверх —
   на планшете 1032x1248 вершина треугольника ровно там, номер не было видно.
   Теперь он ВНУТРИ треугольника, оранжевым, над благодарностью; заголовок
   поднят, чтобы вершина его не задевала. */
.confirm-number {
  position: absolute; left: 0; right: 0; bottom: 28vh; z-index: 1;
  margin: 0; padding: 0 24px;
  text-align: center; color: #CC4100;
  font-size: clamp(56px, 14vw, 132px); font-weight: 800; line-height: 1;
  opacity: 0; transform: translateY(20px);
}
.confirm-number.hidden { display: none; }
#confirm-screen.play .confirm-number {
  animation: confirm-rise calc(450ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(400ms * var(--t)) forwards;
}

/* Шаг 3. Белый треугольник выезжает снизу — тот же знак, что в логотипе. */
.confirm-tri {
  position: absolute; left: 50%; bottom: 0;
  width: min(150vw, 1500px); aspect-ratio: 1 / 0.62;
  margin-left: calc(min(150vw, 1500px) / -2);
  background: #fff;
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
  transform: translateY(100%);
}
#confirm-screen.play .confirm-tri {
  animation: confirm-tri-up calc(450ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(550ms * var(--t)) forwards;
}
@keyframes confirm-tri-up {
  to { transform: translateY(0); }
}

/* Шаг 4. Благодарность ВНУТРИ треугольника. Стоит в нижней его трети —
   выше вершина сходится в точку, и текст туда не поместится. */
.confirm-thanks {
  position: absolute; left: 0; right: 0; bottom: 19vh;
  margin: 0; padding: 0 24px;
  text-align: center; color: #CC4100;
  font-size: clamp(24px, 5vw, 46px); font-weight: 800; line-height: 1.15;
  opacity: 0; transform: translateY(18px);
}
#confirm-screen.play .confirm-thanks {
  animation: confirm-rise calc(400ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(800ms * var(--t)) forwards;
}
@keyframes confirm-rise {
  to { opacity: 1; transform: translateY(0); }
}

/* Кнопка выхода — в нижней трети треугольника, рядом с благодарностью.
   Гостю нужен явный способ уйти, а не догадка «коснитесь экрана»; авто-сброс
   остаётся страховкой на случай, когда от планшета просто отошли. */
.confirm-home {
  position: absolute; left: 50%; bottom: 6vh; transform: translate(-50%, 18px);
  min-height: 72px; padding: 18px 44px; border: none; border-radius: 18px;
  background: linear-gradient(125deg, #FD622C 0%, #CC4100 100%); color: #fff;
  font-family: inherit; font-size: clamp(18px, 3vw, 26px); font-weight: 700;
  cursor: pointer; touch-action: manipulation;
  opacity: 0;
}
#confirm-screen.play .confirm-home {
  animation: confirm-home-rise calc(400ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(800ms * var(--t)) forwards;
}
/* pointer-events через анимацию не гасим — в WebKit на это полагаться нельзя,
   и невидимая кнопка рискует остаться мёртвой навсегда. Ранний тап отсекает
   confirmTapReady в app.js. */
@keyframes confirm-home-rise {
  to { opacity: 1; transform: translate(-50%, 0); }
}

/* Полоска автосброса — тонкая, белая, поверх всего. */
.confirm-progress {
  position: absolute; left: 0; right: 0; top: 0; z-index: 2;
  height: 5px; background: rgba(255,255,255,0.25); overflow: hidden;
}
.confirm-progress i { display: block; height: 100%; width: 0; background: #fff; }

@media (prefers-reduced-motion: reduce) {
  #confirm-screen.play .confirm-flood,
  #confirm-screen.play .confirm-title,
  #confirm-screen.play .confirm-number,
  #confirm-screen.play .confirm-tri,
  #confirm-screen.play .confirm-thanks,
  #confirm-screen.play .confirm-home { animation-duration: 1ms; animation-delay: 0ms; }
}

.scrim.hidden { display: none; }

/* ---- attract / idle screen ---- */
/* Слой над меню, вне прокрутки. В покое стоит ровно на экран выше края —
   translateY(-100%) задаёт app.js. Приходит и уходит пальцем, см. там же.
   touch-action:none обязателен: жест по заставке разбираем сами, иначе Safari
   начинает свой оттяг страницы и палец залипает. */

/* Два слоя один поверх другого. Живой имеет opacity 1, второй грузит
   следующий сюжет и ждёт своей очереди на нуле. Смена — короткий кроссфейд,
   поэтому стыка не видно вообще: под ним всегда лежит соседний кадр, а не
   оранжевый фон героя. */
.attract-slot {
  position: absolute; inset: 0;
  opacity: 0;
  transition: opacity 500ms ease;
}
.attract-slot.is-live { opacity: 1; }
.attract-video, .attract-photo, .attract-fallback {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  /* Явно по центру. Значение и так стоит по умолчанию, но здесь оно важно:
     ролики вертикальные 1080x1934, экран планшета другой пропорции, и cover
     срезает лишнее — срезать он обязан симметрично, иначе кадр уезжает. */
  object-position: center center;
}
.attract-video.hidden, .attract-photo.hidden { display: none; }
@media (prefers-reduced-motion: reduce) {
  .attract-slot { transition: none; }
}
.attract-fallback {
  background: var(--gradient);
  display: flex; align-items: center; justify-content: center;
}
.attract-fallback.hidden { display: none; }
.attract-fallback::after {
  content: "";
  width: 120px; height: 120px; border-radius: 50%;
  background: rgba(255,255,255,0.14);
}

.attract-overlay {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: space-between;
  padding: 8vh 24px 6vh;
  background: linear-gradient(to bottom, rgba(0,0,0,0.28) 0%, rgba(0,0,0,0.02) 30%, rgba(0,0,0,0.02) 60%, rgba(0,0,0,0.45) 100%);
}

.attract-brand {
  text-align: center;
}
.attract-logo {
  font-weight: 800; font-size: 22px; letter-spacing: 0.14em; text-transform: uppercase;
  color: #fff; display: flex; align-items: center; justify-content: center; gap: 12px;
}
.attract-logo .brand-tri { border-bottom-color: #fff; }
.attract-sub {
  font-size: 13px; font-weight: 700; letter-spacing: 0.22em; color: rgba(255,255,255,0.85);
  text-transform: uppercase; margin-top: 6px;
}

/* Swipe-up indicator: a clickable orange gradient triangle, no text label.
   Kept as a real <button> for non-touch/mouse fallback (owner requirement),
   but visually it's just the arrow shape with the same bounce animation. */
.attract-hint {
  display: flex; align-items: center; justify-content: center;
  border: none; background: none; padding: 18px; cursor: pointer;
  min-width: 64px; min-height: 64px;
}
.attract-hint-arrow {
  width: 40px; height: 30px;
  background: var(--gradient);
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
  animation: attract-bounce 1.6s ease-in-out infinite;
}
@keyframes attract-bounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

/* Landscape overrides removed 13.08.2026 — owner confirmed the kiosk iPad is
   mounted vertically only, so the sidebar-nav / wide-grid branch was dead
   weight that every layout change had to be kept in sync with. Portrait is now
   the single supported orientation. The ?nav=side left-rail comparison
   variant was removed 14.08.2026 — owner saw it on the iPad and rejected it:
   categories belong at the top. */

/* ---- on-screen keyboard (simple-keyboard, MIT — see vendor/README.md) ----
   The library ships a neutral grey theme; these overrides pull it onto the
   Kantin glass surface so it reads as part of the sheet, not a bolted-on
   widget. Keys are sized for a finger on a mounted iPad (56px min), not a
   mouse — the library's default 40px rows are too small to hit reliably. */
.keyboard-panel {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
  padding: 10px 12px calc(12px + env(safe-area-inset-bottom));
  background: var(--glass-bg-strong);
  -webkit-backdrop-filter: var(--glass-blur);
  backdrop-filter: var(--glass-blur);
  border-top: 1px solid var(--glass-border);
  box-shadow: 0 -8px 28px rgba(20,24,38,0.16);
}
.keyboard-panel.hidden { display: none; }
/* Выезд снизу и плавный уход вниз (11.10.2026). Только transform и opacity —
   панель со стеклом, перекомпоновку на iPad не трогаем. */
.keyboard-panel { animation: kb-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.keyboard-panel.closing {
  animation: kb-out 240ms cubic-bezier(0.4, 0, 1, 1) forwards;
  pointer-events: none;
}
@keyframes kb-in { from { transform: translateY(100%); } to { transform: none; } }
@keyframes kb-out { from { transform: none; opacity: 1; } to { transform: translateY(100%); opacity: 0.6; } }
@media (prefers-reduced-motion: reduce) {
  .keyboard-panel, .keyboard-panel.closing { animation: none; }
}
.keyboard-head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 2px 4px 10px;
}
.keyboard-label { font-size: 13px; font-weight: 700; color: var(--hint); }
.keyboard-done {
  border: none; background: var(--gradient); color: #fff;
  padding: 10px 22px; border-radius: 14px;
  font-size: 15px; font-weight: 800; cursor: pointer;
  min-height: 48px;
}
/* The cart sheet must clear the keyboard, or the guest types blind into a
   field hidden behind it — the exact failure the native iOS keyboard has. */
body.keyboard-open .sheet { padding-bottom: 320px; }

.keyboard-panel .hg-theme-default {
  background: transparent; padding: 0;
}
.keyboard-panel .hg-button {
  height: 56px; border-radius: 12px;
  background: var(--card); color: var(--text);
  border: 1px solid var(--border);
  box-shadow: var(--card-shadow);
  font-size: 19px; font-weight: 600;
}
.keyboard-panel .hg-button:active,
.keyboard-panel .hg-button.hg-activeButton {
  background: var(--accent-weak); border-color: var(--accent-2);
  transform: scale(0.96);
}
.keyboard-panel .hg-button.hg-functionBtn { font-weight: 800; color: var(--accent); }
/* Numeric layout: a phone pad, not a full-width strip — 3 columns centred so
   the digits stay within thumb reach instead of spanning the whole iPad. */
.keyboard-panel .hg-theme-numeric { max-width: 420px; margin: 0 auto; }
.keyboard-panel .hg-theme-numeric .hg-button { height: 64px; font-size: 24px; }
/* The focused field needs to be obvious once focus is driven by taps on a
   separate panel rather than by a blinking native caret. */
.checkout-form input.input-focused {
  border-color: var(--accent-2);
  box-shadow: 0 0 0 3px var(--accent-weak);
}

/* ---- tactile feedback: motion stands in for haptics ----
   iOS Safari has no vibration API, so on the kiosk iPad the only "felt"
   response available is movement. These are deliberately short (under 600ms)
   — a guest at a counter taps fast, and anything slower gets interrupted by
   the next tap and reads as lag rather than polish. */
.fly-ghost {
  position: fixed; z-index: 90;
  background-size: cover; background-position: center;
  background-color: var(--accent-weak);
  border-radius: 20px;
  pointer-events: none;
  opacity: 0.95;
  /* cubic-bezier with a slight overshoot at the start: the clone leaps away
     from the card, then settles into the cart — reads as "thrown", not slid */
  transition: transform 560ms cubic-bezier(.5,-0.2,.3,1),
              opacity 560ms ease-in,
              border-radius 560ms ease-in;
  will-change: transform, opacity;
}

@keyframes cart-bump {
  0%   { transform: scale(1); }
  30%  { transform: scale(1.14); }
  55%  { transform: scale(0.96); }
  100% { transform: scale(1); }
}
/* Пульсирует СУММА, а не вся панель: масштабировать полосу во всю ширину
   некуда, она уезжала бы за края экрана. */
.cart-bar-total.bump { animation: cart-bump 420ms cubic-bezier(.34,1.56,.64,1); display: inline-block; }

/* Category chips and product cards get a press state that is visible even
   while a finger covers the element — the shadow collapse is readable at the
   edges, the scale alone is not. */
.cat-btn:active { transform: scale(0.96); }
.product-card:active { box-shadow: 0 1px 2px rgba(20,24,38,0.10); }
.btn-primary:active, .keyboard-done:active { transform: scale(0.97); filter: brightness(0.95); }

/* Respect the OS setting: motion sickness is a real accessibility need, and a
   kiosk is used by people who never chose to be here. Feedback degrades to
   instant state changes; nothing becomes unusable. */
@media (prefers-reduced-motion: reduce) {
  .fly-ghost { display: none; }
  .cart-bar-total.bump { animation: none; }
  .cat-btn:active, .product-card:active, .cart-bar-next:active,
  .btn-primary:active, .keyboard-done:active { transform: none; }
}

/* ============================================================================
   Drinkit-reference additions (owner brief 13.08.2026): two-level category nav,
   hero cards, badges, stop-list states, modifier rail + option grid, and the
   cart's composition/upsell blocks. Palette and glass treatment are the ones
   already established above — nothing new is introduced here.
   ========================================================================= */

/* ---- level-1 category buckets ----
   When this row exists it is the top row of the pinned nav block and
   .categories drops to a plain flat strip underneath. Toggled by
   body.has-buckets from renderBuckets. */
.buckets {
  flex: 0 0 auto;
  display: flex; flex-direction: row; gap: 50px;
  overflow-x: auto; overflow-y: hidden;
  padding: 10px 16px 6px;
  /* The active title is parked in the middle (app.js centerActiveBucket), so
     the row needs half a screen of slack on both ends — otherwise the first and
     last tab can never reach the centre. */
  scroll-padding-inline: 50%;
  scrollbar-width: none;
  /* Neighbours fade out toward the edges instead of being cut by the viewport
     (owner, 18.08.2026): semi-transparent in, fully transparent at the rim. */
  -webkit-mask-image: linear-gradient(to right,
      transparent 0, #000 18%, #000 82%, transparent 100%);
  mask-image: linear-gradient(to right,
      transparent 0, #000 18%, #000 82%, transparent 100%);
}
.buckets::-webkit-scrollbar { display: none; }
/* Spacers, not padding: padding on a scroller is clipped at the far end in
   Safari, which would leave the last tab stuck left of centre. */
.buckets-pad { flex: 0 0 50%; }
.buckets.hidden { display: none; }
/* Крупный тонкий заголовок-«бровка» (владелец, 19.08.2026): кегль вдвое
   больше, начертание лёгкое. Активный отличается цветом и чуть большим
   весом — жирного тут больше нет по дизайну. */
.bucket-btn {
  border: none; background: none; cursor: pointer;
  color: var(--hint); font-size: 34px; font-weight: 300;
  padding: 6px 2px; flex: 0 0 auto; white-space: nowrap;
}
.bucket-btn.active { color: var(--text); font-weight: 400; }
/* The neighbours are context, not choices of equal weight — they sit dimmer and
   slightly smaller so the centred title reads as the current page. */
.bucket-btn { transition: color 200ms ease, opacity 200ms ease, transform 200ms ease; }
.bucket-btn:not(.active) { opacity: 0.55; transform: scale(0.94); }

body.has-buckets .buckets {
  position: relative; z-index: 2;
  padding: 22px 0 12px;
  background: transparent;
}
body.has-buckets .categories {
  padding: 4px 12px 14px;
  background: transparent;
}

/* Sections are the anchors the level-2 rail scroll-spies against. */
/* Clears the pinned nav block when a category chip scrolls to its section. */
.menu-section { scroll-margin-top: 118px; margin-bottom: 26px; }

/* ---- badges on the photo (decaf / protein / …) ----
   Derived from the item name in the backend; nothing renders when the name
   says nothing. */
.product-badges {
  position: absolute; top: 10px; left: 10px; z-index: 3;
  display: flex; flex-wrap: wrap; gap: 5px; max-width: calc(100% - 20px);
}
.product-flag {
  font-size: 10px; font-weight: 800; letter-spacing: 0.02em;
  color: #fff; background: var(--accent);
  border-radius: 999px; padding: 4px 9px;
  box-shadow: 0 2px 6px rgba(0,0,0,0.22);
}
.product-flag-protein { background: #C2410C; }   /* тёмно-оранжевый (владелец, 17.09.2026) */
.product-flag-lactosefree { background: #6B4FC4; }
.product-flag-vegan { background: var(--success); }
/* «топ» тем же тёмно-оранжевым, что и «протеин» (владелец, 17.09.2026).
   Графит был моей самодеятельностью — не возвращать. */
.product-flag-top { background: #C2410C; letter-spacing: 0.06em; text-transform: uppercase; }

/* ---- stop list ---- */
.sold-out-plate {
  position: absolute; inset: 0; z-index: 4;
  display: flex; align-items: center; justify-content: center;
  /* Без backdrop-filter, чуть плотнее заливка взамен (было 0.62 + blur(2px)).
     Плашка и фото под ней скроллятся ВМЕСТЕ, картинка размытия не меняется
     никогда — а composited-слой с живым блюром на каждой стоп-позиции всё
     равно пересчитывался при прокрутке. Сегодня в стопе 9 позиций = 9 таких
     слоёв в ленте меню. */
  background: rgba(255,255,255,0.72);
  font-size: 12px; font-weight: 800;
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .sold-out-plate { background: rgba(26,26,26,0.76); }
}
.product-card.unavailable { cursor: default; }
.product-card.unavailable .product-card-body { opacity: 0.5; }
.product-card.unavailable:active { transform: none; }

/* ---- size switch: one chip per volume, sits above the modifier rail ----
   Deliberately wider and flatter than .rail-chip: size is a single required
   answer, not one of several optional groups, so it must not read as just
   another chip in the rail below it. */
.size-row {
  flex: 0 0 auto;
  display: flex; flex-direction: row; gap: 8px;
  padding: 10px 16px 0;
}
.size-chip {
  flex: 1 1 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  min-height: 56px; padding: 8px 6px;
  border-radius: 16px; cursor: pointer; text-align: center;
  background: rgba(20,20,20,0.38);
  -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px);
  border: 1px solid rgba(255,255,255,0.18);
  color: #fff;
}
/* Same fix as .modifier-tile.selected: brand orange + fixed white text, so the
   active size stays legible in both colour schemes. */
.size-chip.active { background: var(--selected-bg); border-color: var(--selected-bg); color: var(--selected-fg); }
.size-chip.active .size-chip-price { opacity: 0.85; }
.size-chip.unavailable { opacity: 0.35; cursor: default; text-decoration: line-through; }
.size-chip-ml { font-size: 14px; font-weight: 700; line-height: 1.1; }
.size-chip-price { font-size: 11px; font-weight: 700; opacity: 0.75; }
.size-chip:active:not(.unavailable) { transform: scale(0.97); }

/* ---- modifier rail: one chip per group, pinned above the CTA bar ---- */
.modifier-rail {
  flex: 0 0 auto;
  display: flex; flex-direction: row; gap: 8px;
  overflow-x: auto; overflow-y: hidden;
  padding: 10px 16px 8px;
  -webkit-overflow-scrolling: touch;
}
.rail-chip {
  position: relative;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
  /* 110, а не 86: «Дополнительно» — одно слово в 87px, переносить его не по
     чему, и на узком чипе оно вылезало за рамку. Ширина общая для всех чипов,
     чтобы рейка оставалась ровной. */
  width: 110px; min-height: 84px; flex: 0 0 auto;
  padding: 10px 8px;
  border-radius: 18px; cursor: pointer; text-align: center;
  background: rgba(20,20,20,0.38);
  -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px);
  border: 1px solid rgba(255,255,255,0.18);
  color: #fff;
}
.rail-chip.chosen { background: var(--selected-bg); border-color: var(--selected-bg); color: var(--selected-fg); }
.rail-chip.chosen .rail-chip-name,
.rail-chip.chosen .rail-chip-price,
.rail-chip.chosen .rail-chip-plus { color: var(--selected-fg); }
.rail-chip.chosen .rail-chip-more { background: var(--selected-icon); color: #fff; }
.rail-chip.open { border-color: var(--accent-2); border-width: 1.5px; }
.rail-chip-plus { font-size: 22px; font-weight: 300; line-height: 1; opacity: 0.9; }
.rail-chip-name {
  font-size: 11px; font-weight: 600; line-height: 1.2;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  /* Страховка на случай, если в iiko заведут название длиннее: метрики шрифта
     на iPad свои, и подогнанной ширины может не хватить — тогда слово
     разорвётся по буквам, а не вылезет наружу. */
  overflow-wrap: anywhere;
}
.rail-chip-price { font-size: 10.5px; font-weight: 700; opacity: 0.75; }
.rail-chip-more {
  position: absolute; top: 6px; right: 6px;
  background: var(--accent); color: #fff;
  font-size: 10px; font-weight: 800;
  min-width: 18px; height: 18px; border-radius: 9px;
  display: flex; align-items: center; justify-content: center;
}
.rail-chip:active { transform: scale(0.97); }
/* Same timing as .modifier-tile so size, group and option chips all light up
   with one motion language. */
.rail-chip, .size-chip {
  transition: background-color 160ms ease, border-color 160ms ease,
              color 160ms ease, transform 90ms ease;
}

/* ---- option grid for the opened group, floating over the photo ---- */
.modifier-sheet {
  flex: 0 0 auto;
  -webkit-overflow-scrolling: touch;
  /* Collapsed by default; .sheet-open (set by app.js one frame after the grid
     is in the DOM) expands it. Animating max-height rather than height keeps
     the grid's natural size authoritative — the sheet holds anywhere from 3 to
     12 option tiles depending on the group. */
  /* Без opacity (владелец, 19.08.2026): лист раскрывался с 0 до 1, поэтому
     плитки сперва были полностью прозрачными и только потом доходили до своей
     полупрозрачности. Теперь они сразу в рабочем виде, а появление целиком
     держат высота, сдвиг и маска на краях. */
  /* Высота гонится через height, а НЕ только через max-height (владелец,
     19.08.2026: «с молока на сиропы переключается резко»). max-height умеет
     красиво показывать РОСТ — контент уже в DOM и постепенно раскрывается
     из-под ограничения. А на сжатии он бесполезен: новая, более низкая сетка
     встаёт мгновенно, и ограничивать уже нечего — лист схлопывался рывком.
     Замерено по кадрам: было 376-376-376, стало 549-537-500-412-381-376. */
  height: 0; max-height: 0; transform: translateY(-10px);
  overflow: hidden;
  padding: 0 16px;
  /* Мягкий край вместо прямого среза (владелец, 18.08.2026): на полпути
     раскрытия сетка резалась плоской линией поперёк ряда плиток.
     Разбежка ужата с 34px до 6px (владелец, 19.08.2026): плитка высотой 116px,
     и рампа на 34px гасила треть плитки — ряд выезжал сначала прозрачным и
     только потом добирал свою полупрозрачность. Это и было тем «сначала
     прозрачные, потом полупрозрачные», а не opacity листа. 6px — это уже не
     затухание, а просто сглаженная кромка: плитка появляется сразу в рабочем
     виде, но жёсткой линии по-прежнему нет. */
  -webkit-mask-image: linear-gradient(to bottom,
      transparent 0, #000 4px, #000 calc(100% - 6px), transparent 100%);
  mask-image: linear-gradient(to bottom,
      transparent 0, #000 4px, #000 calc(100% - 6px), transparent 100%);
  /* Symmetric ease-in-out (owner, 18.08.2026). The previous curve was an
     ease-OUT: measured in a browser it covered 58% of the distance in the first
     100ms, so the close read as "it just disappears". This one starts slow,
     moves through the middle, and settles slowly — the same shape in both
     directions. */
  transition: height 400ms cubic-bezier(0.65, 0, 0.35, 1),
              max-height 400ms cubic-bezier(0.65, 0, 0.35, 1),
              padding 400ms cubic-bezier(0.65, 0, 0.35, 1),
              transform 400ms cubic-bezier(0.65, 0, 0.35, 1);
}
.modifier-sheet.sheet-open {
  /* --sheet-h is the grid's measured height, set by app.js right before the
     entrance and re-measured on every change while open (app.js measureSheet).
     Plain px, already clamped to 46vh there — a CSS min() here would express the
     same thing but Safari does not interpolate it reliably in max-height. */
  height: var(--sheet-h, 46vh);
  max-height: var(--sheet-h, 46vh);
  transform: none;
  padding: 10px 16px 2px;
  overflow-y: auto;
}
/* Settled = fully open and everything fits, so nothing is being clipped and the
   fade would only dim a real, tappable last row. The mask is for motion and for
   scrollable content, not for a resting list. */
.modifier-sheet.sheet-settled {
  -webkit-mask-image: none;
  mask-image: none;
}
.modifier-sheet.hidden { display: none; }
@media (prefers-reduced-motion: reduce) {
  .modifier-sheet, .modifier-tile, .rail-chip, .size-chip { transition: none; }
}
.modifier-sheet-grid {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
/* Заголовок блока доп-порций внутри листа выбора молока. padding, а НЕ margin:
   measureSheet складывает высоты прямых детей листа, и внешние отступы в эту
   сумму не попадут — лист окажется ниже своего содержимого. */
.sheet-extra-title {
  padding: 16px 2px 8px;
  font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: rgba(255,255,255,0.62);
}
.modifier-tile {
  position: relative;
  display: flex; flex-direction: column; align-items: center; gap: 5px;
  padding: 12px 8px 10px; border-radius: 18px; cursor: pointer; text-align: center;
  /* Плоская полупрозрачность вместо backdrop-filter (владелец, 19.08.2026:
     «появляются сначала прозрачными, потом полупрозрачными»). Причина была
     в блюре: пока лист анимирует высоту, браузер размытие подложки не считает,
     и плитка выезжает как жидкая плёнка поверх фото; блюр включается, когда
     анимация улеглась — вот и «сначала прозрачная, потом полупрозрачная».
     Теперь состояние в движении и в покое одно и то же, переходить нечему.
     Альфа осталась 0.42: замер пипеткой показал, что в покое плоская плитка
     даёт ровно тот же цвет, что давала с блюром (113,112,107) — подложка под
     листом достаточно ровная, чтобы размытие ничего не меняло в плотности.
     Бонусом уходит 12 одновременных backdrop-блюров на iPad киоска. */
  background: rgba(20,20,20,0.42);
  border: 1px solid rgba(255,255,255,0.18);
  color: #fff; min-height: 116px;
  /* The tile survives a re-render now (app.js patches it in place), so the
     white/orange swap can actually animate instead of snapping. */
  transition: background-color 160ms ease, border-color 160ms ease,
              color 160ms ease, transform 90ms ease;
}
/* Chosen tiles go brand orange with hard-white text (owner, 18.08.2026).
   They used to go white with color: var(--text) — but --text flips to near-white
   in the dark colour scheme the kiosk iPad and guests' phones default to, so the
   label vanished into the white tile. Both values are fixed literals here on
   purpose: the selected state must not depend on the light/dark palette. */
.modifier-tile.selected {
  background: var(--selected-bg); border-color: var(--selected-bg); color: var(--selected-fg);
}
.modifier-tile.selected .modifier-tile-name,
.modifier-tile.selected .modifier-tile-price,
.modifier-tile.selected .modifier-tile-plus { color: var(--selected-fg); }
.modifier-tile.selected .modifier-tile-check { color: var(--selected-icon); }
.modifier-tile.selected .modifier-tile-price { opacity: 0.85; }
.modifier-tile.out { opacity: 0.45; cursor: default; }
.modifier-tile-name {
  font-size: 11.5px; font-weight: 600; line-height: 1.2;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.modifier-tile-price { font-size: 10.5px; font-weight: 700; opacity: 0.72; }
.modifier-tile-plus, .modifier-tile-check {
  margin-top: auto; font-size: 18px; font-weight: 700; line-height: 1;
}
.modifier-tile-check { color: var(--accent); }
.modifier-tile-out { margin-top: auto; font-size: 10.5px; font-weight: 700; }
.stepper-tile {
  margin-top: auto; gap: 6px; padding: 4px;
  background: var(--surface-2); border-color: transparent;
}
.stepper-tile .stepper-btn { width: 30px; height: 30px; font-size: 16px; border-radius: 10px; }
/* Inside a selected (orange) tile the stepper keeps its own contrast:
   translucent white pill, white glyphs — var(--surface-2) would be a pale
   grey block with white text on it in the light scheme. */
.modifier-tile.selected .stepper-tile { background: #EEF1F4; }
.modifier-tile.selected .stepper-btn { background: #FFFFFF; color: var(--selected-fg); }
.modifier-tile.selected .stepper-value { color: var(--selected-fg); }
.stepper-tile .stepper-value { min-width: 18px; font-size: 13px; }
.stepper-btn:disabled { opacity: 0.4; cursor: default; }
.modifier-tile:active { transform: scale(0.97); }

/* ---- cart: prep plate, composition lines, order nutrition, upsell ---- */
.cart-prep {
  background: var(--surface-2); border-radius: 14px;
  padding: 11px 14px; text-align: center;
  font-size: 12.5px; font-weight: 700; color: var(--hint);
}
.cart-prep.hidden { display: none; }

/* Additions, removals and the stop-list plate all live in the row's "mods"
   grid area, so they need to stack in normal flow rather than overlap. */
.cart-item-row {
  grid-template-areas:
    "image name"
    "image price"
    "image mods"
    "image footer";
}
.cart-item-modlines {
  grid-area: mods;
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
}
/* The wrapper owns the grid area now; the lines inside flow normally. */
.cart-item-modlines .cart-item-mods { grid-area: auto; }
.cart-item-added { color: var(--text); font-weight: 600; }
.cart-item-removed { color: var(--hint); }
.cart-item-stopped {
  display: inline-flex; align-items: center; gap: 6px;
  background: var(--accent-weak); color: var(--accent);
  border-radius: 10px; padding: 6px 10px; margin-top: 4px;
  font-size: 11px; font-weight: 700;
}


.cart-upsell { margin-bottom: 18px; }
.cart-upsell.hidden { display: none; }
.cart-upsell-title { font-size: 22px; font-weight: 800; margin-bottom: 10px; }
.cart-upsell-rail {
  display: flex; gap: 12px; overflow-x: auto; padding-bottom: 4px;
  -webkit-overflow-scrolling: touch;
}
/* Плитки «вместе вкуснее» крупнее (владелец, 18.09.2026: «чтобы видно было,
   какие позиции там»). 132px давали миниатюру, по которой блюдо не узнать —
   гость не покупает то, что не разглядел. Фото стало квадратным: при 1.3/1
   выпечку резало по краям сильнее всего. */
.upsell-card {
  flex: 0 0 auto; width: 210px;
  background: var(--card); border-radius: 20px; box-shadow: var(--card-shadow);
  overflow: hidden; display: flex; flex-direction: column;
}
.upsell-card-image { width: 100%; aspect-ratio: 1 / 1; background: var(--surface-2); overflow: hidden; }
.upsell-card-image img { width: 100%; height: 100%; object-fit: cover; }
.upsell-card-image .product-placeholder span { font-size: 12px; }
.upsell-card-name {
  font-size: 17px; font-weight: 600; line-height: 1.25; padding: 12px 14px 0;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.upsell-card-row {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 14px 14px; margin-top: auto;
}
.upsell-card-price { font-size: 17px; font-weight: 700; }
/* Счётчик уже добавленного: рейка больше не прячется после первого «плюса»,
   поэтому гость должен видеть, сколько он уже взял. */
.upsell-card-qty {
  font-size: 13px; font-weight: 700; color: var(--accent);
  padding: 0 14px 10px; margin-top: -6px;
}
.upsell-card-qty.hidden { display: none; }
.upsell-add {
  width: 44px; height: 44px; border-radius: 50%; border: none;
  background: var(--surface-2); color: var(--text);
  font-size: 24px; font-weight: 700; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.upsell-add:active { transform: scale(0.92); background: var(--accent-weak); }

/* ---------- Шаг 3: оранжевая заливка и экран имени ---------- */
/* Жидкость поднимается снизу и НЕ стекает обратно: в отличие от перехода между
   страницами (промт владельца), здесь она становится фоном следующего шага.
   Двигается только transform — единственное, что iPad отдаёт композитору
   бесплатно; height/clip-path пересчитывались бы покадрово. */
#name-screen { background: transparent; pointer-events: none; }
#name-screen.on { pointer-events: auto; }

.liquid {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 130%;
  transform: translateY(100%);
}
#name-screen.on .liquid {
  animation: liquid-rise calc(620ms * var(--t)) cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes liquid-rise {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

/* Тело жидкости — плоская заливка без глянца. Волна сидит на её верхнем крае
   двумя слоями: передний и отстающий задний. Разная длительность у слоёв и
   даёт «неровный, живой» край вместо одной правильной синусоиды. */
.liquid-body {
  position: absolute; left: 0; right: 0; top: 140px; bottom: 0;
  background: linear-gradient(180deg, #FF6A00 0%, #F45D00 60%, #E25200 100%);
}
.liquid-wave {
  position: absolute; left: -50%; width: 200%; top: 0; height: 160px;
  fill: #FF6A00;
}
.liquid-wave path { fill: inherit; }
.liquid-wave-back { fill: #F45D00; top: 18px; opacity: 0.85; }
#name-screen.on .liquid-wave {
  animation: liquid-drift 3.1s ease-in-out infinite alternate;
}
#name-screen.on .liquid-wave-back {
  animation: liquid-drift 4.7s ease-in-out infinite alternate-reverse;
}
@keyframes liquid-drift {
  from { transform: translateX(-4%) scaleY(0.9); }
  to   { transform: translateX(4%)  scaleY(1.12); }
}

/* Имя гостя набирается шрифтом Rolcak Regular (владелец, 18.09.2026).
   Прислан был как .woff2, но внутри лежал PostScript Type 1 — такой формат
   Safari не понимает вовсе. Здесь лежит честный woff2, пересобранный из него
   через fontforge (исходник: Fontself Maker, Qr Design).
   ВНИМАНИЕ: в шрифте НЕТ кириллицы — 52 латинских буквы, 0 русских. На русское
   имя Safari молча подставит системный шрифт, буква за буквой.
   font-display: swap, а не block: block дал бы невидимый текст — гость печатал
   бы в пустоту и не понял, работает киоск или нет. */
@font-face {
  font-family: "Rolcak";
  src: url("/kiosk/static/fonts/rolcak-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Вопрос и набранное имя — один и тот же крупный белый шрифт: отдельного поля
   ввода нет, гость пишет прямо по заливке (владелец, 18.09.2026). */
.name-ask, .name-typed {
  position: absolute; left: 0; right: 0;
  margin: 0; padding: 0 32px;
  text-align: center; color: #fff;
  opacity: 0;
}
.name-ask {
  top: 26vh;
  font-size: clamp(40px, 9vw, 84px); font-weight: 800; line-height: 1.05;
}
.name-typed {
  top: 38vh;
  /* Rolcak только на имени: вопрос «Как вас зовут?» остаётся прежним шрифтом.
     Запасной — тот же стек, что у всего киоска: он же подхватывает кириллицу,
     которой в Rolcak нет. */
  font-family: "Rolcak", "SF Mono", "JetBrains Mono", ui-monospace, "Inter Tight",
               -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: clamp(34px, 7.5vw, 68px); font-weight: 400;
  min-height: 1.2em;
}
#name-screen.on .name-ask,
#name-screen.on .name-typed {
  animation: confirm-rise calc(420ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(560ms * var(--t)) forwards;
}
/* Курсор мигает только пока экран активен — иначе он продолжает перерисовываться
   под следующим экраном и без причины будит композитор. */
.name-caret {
  display: inline-block; width: 3px; height: 1em; margin-left: 6px;
  background: #fff; vertical-align: -0.12em;
}
#name-screen.on .name-caret { animation: name-caret 1s steps(1) infinite; }
@keyframes name-caret { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }

/* Кубик случайного имени — между именем и клавиатурой (та начинается ~70vh). */
.name-random {
  /* Центр через margin, не transform: анимация confirm-rise переписывает transform. */
  position: absolute; left: 0; right: 0; top: 50vh; margin: 0 auto; width: fit-content;
  min-height: 64px; padding: 0 32px;
  border: 3px solid rgba(255, 255, 255, 0.85); border-radius: 999px;
  background: transparent; color: #fff;
  font: inherit; font-size: clamp(20px, 3.2vw, 28px); font-weight: 700;
  opacity: 0;
}
#name-screen.on .name-random {
  animation: confirm-rise calc(420ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(560ms * var(--t)) forwards;
}
.name-random:active { transform: scale(0.97); }
#name-screen .field-hint {
  position: absolute; left: 0; right: 0; top: 59vh;
  text-align: center; color: #fff; opacity: 0.9; font-size: 20px;
}
#name-screen .field-hint.hidden { display: none; }

/* «Здесь или с собой» — верх экрана, НАД вопросом об имени: клавиатура стоит
   внизу и не должна ничего закрывать (владелец, 20.09.2026). Всё, что ниже
   50vh, отдано ей. */
/* «Назад» — левый верхний угол, над заливкой; 64px под палец. */
.name-back {
  position: absolute; left: 24px; top: calc(24px + env(safe-area-inset-top, 0px)); z-index: 2;
  width: 64px; height: 64px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: 3px solid rgba(255, 255, 255, 0.85); border-radius: 999px;
  background: transparent; color: #fff; opacity: 0;
}
.name-back svg { width: 32px; height: 32px; }
#name-screen.on .name-back {
  animation: confirm-rise calc(420ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(560ms * var(--t)) forwards;
}
.name-back:active { transform: scale(0.94); }
.serve-ask {
  position: absolute; left: 0; right: 0; top: 7vh;
  margin: 0; text-align: center; color: #fff; opacity: 0;
  font-size: clamp(20px, 3.4vw, 30px); font-weight: 700;
}
.serve-choice {
  position: absolute; left: 32px; right: 32px; top: 13vh;
  display: flex; gap: 20px; opacity: 0;
}
#name-screen.on .serve-ask,
#name-screen.on .serve-choice {
  animation: confirm-rise calc(420ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(560ms * var(--t)) forwards;
}
/* Невыбранная — контур, выбранная — залита белым. Пока не тронули, обе в
   контуре: подача обязательна, и «по умолчанию здесь» скрыло бы вопрос. */
.serve-btn {
  flex: 1; min-height: 96px;
  border: 3px solid rgba(255, 255, 255, 0.85); border-radius: 26px;
  background: transparent; color: #fff;
  font: inherit; font-size: clamp(24px, 4vw, 34px); font-weight: 800;
}
.serve-btn.on {
  background: #fff; color: #CC4100;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
}
.serve-btn:active { transform: scale(0.97); }

@media (prefers-reduced-motion: reduce) {
  #name-screen.on .liquid { animation-duration: 1ms; }
  #name-screen.on .liquid-wave,
  #name-screen.on .liquid-wave-back,
  #name-screen.on .name-caret { animation: none; }
  #name-screen.on .name-ask,
  #name-screen.on .name-typed,
  #name-screen.on .serve-ask,
  #name-screen.on .serve-choice,
  #name-screen.on .name-back,
  #name-screen.on .name-random { animation-duration: 1ms; animation-delay: 0ms; }
}

@media (prefers-reduced-motion: reduce) {
  .rail-chip:active, .modifier-tile:active, .upsell-add:active { transform: none; }
}

/* ---- payment screen (qr mode) — payment.js state machine views ---- */
/* Экран оплаты — белый (владелец, 07.09.2026): пока идёт списание на терминале
   Kaspi, на планшете только белый фон и треугольник. Тёмная подложка со
   стеклом отсюда убрана, иначе белый знак на ней не читается. */
/* Экран оплаты НЕ закрашивает всё белым (владелец, 18.09.2026: «сначала должен
   блюриться экран, и затем на этом блюре плавно появиться QR»). Раньше здесь
   стоял сплошной #fff: корзина и витрина исчезали разом, карточка возникала на
   пустом листе — перехода не читалось вовсе.
   Размывает НЕ этот слой: backdrop-filter на весь экран — самое дорогое, что
   есть на этом планшете. Размытие вешается на сами узлы под ним через
   body.paying, ровно как у корзины (см. body.checkout-open выше). */
.payment-screen {
  position: fixed; inset: 0; z-index: 90;
  display: flex; align-items: center; justify-content: center;
  background: rgba(255, 255, 255, 0.5);
  padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
           max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
}
.payment-screen.hidden { display: none; }
/* Под экраном оплаты размывается всё, что было на виду: и витрина, и сама
   корзина — гость видит СВОЙ экран за матовым стеклом, а не белый лист.
   Ставится скачком, без transition: анимированный blur на всю площадь — это
   покадровая фильтрация полного экрана, самая дорогая вещь в киоске (та же
   причина, что у body.checkout-open). Плавность несёт карточка. */
body.paying .panel,
body.paying .cart-bar,
body.paying #cart-sheet { filter: blur(14px); }

.pay-card {
  width: min(560px, 92vw); max-height: min(88dvh, 900px); overflow-y: auto;
  border-radius: 28px; padding: 40px 44px;
  background: #fff; color: #1A1A1A;
  display: flex; flex-direction: column; align-items: center; gap: 18px;
  text-align: center;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
  /* Задержка 140мс — чтобы размытие успело встать ДО появления карточки.
     Иначе оба события видны одним кадром и порядок не читается. */
  opacity: 0; transform: translateY(24px) scale(0.96);
  animation: pay-card-in calc(340ms * var(--t)) cubic-bezier(0.22, 0.8, 0.3, 1) calc(140ms * var(--t)) forwards;
}
@keyframes pay-card-in {
  to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .pay-card { animation-duration: 1ms; animation-delay: 0ms; }
}
.pay-title { margin: 0; font-size: 30px; font-weight: 700; }
.pay-qr { width: min(62vw, 440px); aspect-ratio: 1; background: #fff; border-radius: 20px; padding: 12px; }
.pay-qr svg { display: block; width: 100%; height: 100%; }
.pay-amount { font-size: 40px; font-weight: 800; color: var(--accent); letter-spacing: 0.5px; }
.pay-hint { margin: 0; font-size: 17px; color: #6B6B6B; line-height: 1.5; }
.pay-waiting { margin: 0; font-size: 18px; font-weight: 600; }
#pay-countdown { color: var(--accent); font-variant-numeric: tabular-nums; }
.pay-btn {
  min-height: 58px; min-width: 240px; padding: 14px 28px; border: none;
  border-radius: 16px; font-size: 18px; font-weight: 700; font-family: inherit;
  cursor: pointer; touch-action: manipulation;
}
.pay-btn-primary { background: var(--gradient); color: #fff; }
.pay-btn-ghost { background: var(--surface-2); color: var(--text); }
.pay-done, .pay-fail {
  width: 96px; height: 96px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 46px; font-weight: 800; color: #fff;
}
.pay-done { background: var(--success); }
.pay-fail { background: var(--danger); }
/* Треугольник-загрузка. Белая заливка на белом фоне держится только за счёт
   контура, поэтому бледная подложка-трек обязательна: без неё в момент, когда
   дуга на другой стороне, знак пропадает целиком. */
.tri-loader { width: min(46vw, 300px); height: auto; overflow: visible; }
.tri-loader path {
  fill: #fff;
  stroke-width: 7; stroke-linecap: round; stroke-linejoin: round;
}
.tri-loader-track { stroke: rgba(253, 98, 44, 0.16); }
.tri-loader-arc {
  fill: none; stroke: url(#tri-grad);
  stroke-dasharray: 24 76;
  animation: tri-run 1.6s linear infinite;
}
/* Убывающий dashoffset гонит штрих по направлению пути, а путь задан от
   вершины вправо-вниз — то есть по часовой стрелке. */
@keyframes tri-run { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }

@media (prefers-reduced-motion: reduce) {
  .tri-loader-arc { animation-duration: 4s; }
}
.pay-dev {
  display: flex; gap: 10px; align-items: center; margin-top: 6px;
  padding: 10px 14px; border-radius: 14px; border: 2px dashed var(--danger);
}
.pay-dev span { font-size: 13px; font-weight: 800; color: var(--danger); }
.pay-btn-dev { min-width: 0; min-height: 44px; padding: 8px 16px; font-size: 15px; background: var(--surface-2); color: var(--text); }

/* ---- bucket switch: the grid follows the finger, app.js drives the transform ----
   touch-action: pan-y leaves vertical scrolling to the browser and gives us the
   horizontal gesture, so the swipe never fights the menu scroll. */
.menu {
  touch-action: pan-y;
  /* Постоянного will-change: transform здесь больше нет: он держал ВСЮ сетку
     меню отдельным слоем композитора всё время — гигабайт-класс растр на
     длинном разделе и перерастеризация при каждом скролле. Слой нужен только
     на время свайпа между разделами, и инлайновый transform из setMenuOffset
     поднимает его сам ровно на этот отрезок. */
}
@media (prefers-reduced-motion: reduce) {
  .menu { transition: none !important; }
}

/* The cold-load flash guard (body.nav-ready) lived here: the nav block carried
   a blur + gradient over the banner video and painted a hard-edged band before
   layout settled. Both the banner and that treatment are gone — a flat opaque
   bar has nothing to flash — so the guard went with them. */

/* ---- tap ripple ----
   Круг из точки касания на любом экране киоска. Живёт в <body>, поэтому не
   зависит от overflow/transform карточек и не ломает вёрстку. pointer-events:none
   — обязательно, иначе круг перехватит клик по товару под собой. */
.tap-ripple {
  position: fixed; z-index: 9999; pointer-events: none;
  width: 12px; height: 12px; margin: -6px 0 0 -6px;
  border-radius: 50%;
  border: 2px solid rgba(255,255,255,0.85);
  background: rgba(255,255,255,0.18);
  animation: tap-ripple 520ms cubic-bezier(0.22,0.61,0.36,1) forwards;
}
@keyframes tap-ripple {
  from { transform: scale(1);  opacity: 0.85; }
  to   { transform: scale(11); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .tap-ripple { display: none; } }


/* =====================================================================
   ГЛАВНЫЙ ЭКРАН ПО СПЕКЕ (kiosk/docs/kantin-usecase-spec.md)
   Три слоя: HERO — фон страницы, PANEL — навигация и лента, BAR — шапка.
   ===================================================================== */

/* HERO. Именно ФОН, а не блок в потоке: панель едет поверх него, поэтому при
   раскрытии фотография не исчезает — она уходит только дальнейшей прокруткой,
   с параллаксом (спека §2, §6). */
.hero {
  position: fixed; inset: 0; z-index: 1;
  height: var(--app-h, 100dvh);
  background: var(--gradient);
  overflow: hidden;
  /* scale 1 + .04p — герой чуть наплывает по мере раскрытия (§4, фаза A) */
  /* Только наплыв от раскрытия. Параллакс прокрутки убран: панель закрывает
     героя целиком, эффекта не видно, а в покое он двигал фотографию от чужой
     прокрутки. */
  transform: scale(1);
  transform-origin: 50% 35%;
}
.hero-scrim {
  position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(to bottom, rgba(0,0,0,.55), rgba(0,0,0,0) 46%);
  opacity: 0;
}
.hero-overlay {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; align-items: center;
  padding: calc(env(safe-area-inset-top, 0px) + 48px) 24px var(--peek-h);
  pointer-events: none;
}
.hero-overlay > * { pointer-events: auto; }
/* Вордмарк уходит РАНЬШЕ, чем приедет бар: двух логотипов на экране быть не
   должно (пункт приёмки §12). */
.hero-brand { opacity: 1; }
.hero-caption {
  margin-top: auto;
  font-size: 44px; font-weight: 600; color: #fff; text-align: center;
  text-shadow: 0 2px 24px rgba(0,0,0,.35);
  opacity: 1;
}
/* Треугольник-аффорданс гаснет за первые 15% пути (§4). */
.hero .attract-hint { opacity: 1; }

/* PANEL. Высотой в экран минус бар. В покое сдвинут вниз на travel, видна
   только верхняя кромка. Двигается ТОЛЬКО transform. */
.panel {
  position: fixed; left: 0; right: 0; z-index: 2;
  top: var(--bar-h);
  height: calc(var(--app-h, 100dvh) - var(--bar-h));
  display: flex; flex-direction: column;
  /* В OPEN панель встаёт вплотную под бар и закрывает экран целиком: заставки
     при открытом меню быть не должно (владелец, 25.08.2026 — расхождение со
     спекой §4, где герой остаётся виден на 42%). В покое сдвинута на travel,
     из-под кромки видно ровно peekHeight. */
  transform: translateY(100%);   /* стартовое положение, дальше ведёт app.js */
  will-change: transform;
  /* Тени НЕТ. Она ложилась на фотографию заставки прямо над кромкой меню и
     читалась как грязная полоса (владелец, 25.08.2026). Границу теперь держит
     сам градиент подложки. Возвращать не нужно: анимировать её всё равно
     нельзя — полноэкранное размытие перерисовывается каждый кадр. */
  box-shadow: none;

  /* touch-action: none УБРАН. Он глушил нативную прокрутку каталога внутри
     панели, и каждый свайп по меню превращался в перетаскивание панели —
     вместе с ней ехала и заставка (владелец, 25.08.2026). Прокрутку ведёт
     .page-scroll, а панель забирает жест только осознанно, из JS. */
}

/* Фон панели — ЗАЛИВКА со съезжающимися стопами, НЕ маска. Маска съела бы
   текст вместе с подложкой (§9). Стопы стягиваются к нулю по мере раскрытия,
   и при p=1 фон сплошной — это отдельный пункт приёмки (§12). */
/* Фон панели — ДВА неподвижных слоя, у которых меняется только прозрачность.
   Раньше это был один слой, которому JS каждый кадр пересобирал строку
   градиента: полноэкранная перерисовка на кадр, отсюда прерывистость
   опускания (владелец, 25.08.2026). Прозрачность композитор меняет даром. */
.panel-bg {
  position: absolute; inset: 0; z-index: -1;
  background: #fff;
  opacity: 0;            /* значение пишет app.js: растёт вместе с раскрытием */
}
.panel-edge {
  position: absolute; inset: 0; z-index: -1;
  /* Стопы посажены на ИЗМЕРЕННЫЕ строки навигации, а не на круглые числа:
     NAV-1 занимает 0..85px от верха панели, NAV-2 — 85..163, выглядывает 176.
     Владелец (25.08.2026) просил, чтобы у самой кромки было прозрачно, поэтому
     первые 40px — чистый ноль, дальше подложка набирает плотность и к концу
     выглядывающей полосы становится сплошной. */
  background: linear-gradient(to bottom,
    rgba(255,255,255,0)     0px,
    rgba(255,255,255,0)    40px,
    rgba(255,255,255,.35)  90px,
    rgba(255,255,255,.80) 140px,
    rgba(255,255,255,1)   185px);
  opacity: 1;            /* и гаснет, когда панель раскрыта */
}

/* Текст навигации: меняется ТОЛЬКО альфа, чёрный остаётся чёрным. Нулей нет —
   в покое оба ряда читаются и кликаются (§3). */
/* Альфы пишет app.js: меняется только прозрачность, чёрный остаётся чёрным. */

/* BAR. Отдельная фаза: выезжает только после того, как панель встала (§4). */
.appbar {
  position: fixed; top: 0; left: 0; right: 0; z-index: 3;
  transform: translateY(-100%);
  /* Уход быстрее прихода: закрытие обязано ощущаться быстрее раскрытия (§7).
     --motion пишет app.js из константы MOTION — темп бара обязан совпадать с
     темпом панели, иначе нахлёст (BAR_LEAD) съезжает. Базовые числа не трогать. */
  transition: transform calc(180ms * var(--motion, 1)) var(--ease-exit);
}
.appbar.in { transition: transform calc(280ms * var(--motion, 1)) var(--ease-enter); }
.appbar.in { transform: translateY(0); }
/* Содержимое со сдвигом по времени (§4, фаза B). */
.appbar .brand, .appbar .brand-sub, .appbar .clock, .appbar .back-to-attract,
.appbar .lang-switch {
  opacity: 0;
  transition: opacity calc(200ms * var(--motion, 1)) linear;
}
.appbar.in .brand, .appbar.in .brand-sub { opacity: 1; transition-delay: calc(90ms * var(--motion, 1)); }
.appbar.in .lang-switch { opacity: 1; transition-delay: calc(120ms * var(--motion, 1)); }
.appbar.in .clock { opacity: 1; transition-delay: calc(150ms * var(--motion, 1)); }
.appbar.in .back-to-attract { opacity: 1; transition-delay: calc(180ms * var(--motion, 1)); }

/* Пейджер разделов: едет только контент, шапка и навигация неподвижны (§5). */
/* Окно: только обрезает и НЕ двигается. */
.pager {
  flex: 1; min-height: 0;
  overflow: hidden;
  touch-action: pan-y;
}
/* Лента страниц: только двигается. */
.pager-track {
  display: flex;
  height: 100%;
  will-change: transform;
}
.page {
  flex: 0 0 100%; min-width: 100%;
  display: flex; flex-direction: column; min-height: 0;
}
/* Вертикальный скролл У КАЖДОЙ страницы свой и сохраняется (§5). */
.page-scroll {
  flex: 1; min-height: 0;
  overflow-y: auto; overflow-x: hidden;
  background: transparent;
  /* Вертикаль ведёт браузер, горизонталь остаётся нам под пейджер. */
  touch-action: pan-y;
}

/* Обе строки навигации прилипают ОДНИМ блоком, не по очереди (§6). */
.nav-stack.stuck {
  box-shadow: 0 2px 12px rgba(0,0,0,.08);
  transition: box-shadow 120ms linear;
}

/* «Уменьшение движения»: без перемещений, только кросс-фейд (§11). */
@media (prefers-reduced-motion: reduce) {
  .panel, .pager, .appbar { transition: opacity 150ms linear !important; }
  .hero { transform: none; }
}
