
  /* ── Здорово design tokens ────────────────────────────────────────────────
     One palette, named once. Every hard-coded hex that used to sit in a rule
     below is now a var, so the next restyle is a token change and not a sweep
     through 60 selectors. Dark values are used ONLY by the camera screen: the
     app is light, the viewfinder is not. */
  :root {
    --bg:        #FAFAF4;   /* app background */
    --surface:   #FFFFFF;   /* cards */
    --sunken:    #F1F1E8;   /* controls sitting ON the app background */
    /* …and the same idea INSIDE a white sheet, where it has to be far lighter.
       --sunken against #FAFAF4 is a gentle step down; against #FFFFFF it is a
       block of beige, and a sheet of them reads as heavy and striped. Six rows
       of nutrition should recede behind their numbers, not compete with them. */
    --inset:     #F8F8F3;
    --inset-ln:  #EFEFE7;
    --line:      #E7E7DC;
    --ink:       #23291B;   /* primary text */
    --ink-2:     #4A5240;
    --muted:     #7A806E;
    --faint:     #A9AE9C;   /* inactive tabs, placeholders */
    --green:     #6FA83F;   /* the brand, and every affirmative action */
    --green-dk:  #578630;
    --green-tint:#E9EDDC;
    --green-soft:#F6FAF0;
    --amber:     #F9C33C;   /* the wheat in the logo — accent only, never a verdict */
    --cam:       #14170F;   /* viewfinder backdrop */
    --shadow:    0 1px 3px rgba(35,41,27,.06);
    --shadow-lg: 0 10px 24px rgba(111,168,63,.35);
  }
  * { box-sizing: border-box; margin: 0; }
  body { font-family: 'Nunito', -apple-system, system-ui, sans-serif;
         background: var(--bg); color: var(--ink);
         -webkit-font-smoothing: antialiased; }
  /* 100dvh, not 100vh. On a phone 100vh is the height the page WOULD have if
     the address bar were hidden, so while it is showing the app is taller than
     the screen and the scanner — a screen with nothing to scroll — scrolls.
     dvh tracks the bar as it comes and goes. 100vh stays first as the fallback
     for anything that does not know dvh. */
  #app { max-width: 480px; margin: 0 auto; min-height: 100vh; min-height: 100dvh;
         display: flex; flex-direction: column; position: relative; }
  /* The scanner has nothing to scroll, so it must not scroll. Two separate
     things make it anyway on an iPhone: with a notch the safe-area insets sit
     inside 100dvh, and Safari rubber-bands the document vertically even when
     the content fits. Locking the document while the viewfinder is up kills
     both, and every screen that DOES have something to read is untouched.
     The product sheet keeps working over the camera because the overlay
     scrolls inside itself, not on the page. */
  html.lock, html.lock body { height: 100%; overflow: hidden;
         overscroll-behavior: none; }
  /* Locked means unscrollable, so anything below the fold is lost rather than
     merely out of view. Clamp the column to the viewport instead of letting it
     grow past it, and let the parts that CAN shrink do the absorbing. */
  html.lock #app { height: 100vh; height: 100dvh; max-height: 100dvh; }
  #reader { width: 100%; }
  .hint { text-align: center; color: var(--faint); padding: 12px; font-size: 15px; }

  /* ── the shell: five screens, one visible at a time ──────────────────────── */
  .screen { display: none; flex: 1; flex-direction: column; }
  .screen.on { display: flex; }
  .scrn-pad { padding: 0 20px; }
  /* the mockup's 68px top padding is an iOS status bar drawn into the canvas.
     On a real phone it is the safe-area inset, which is 0 in a browser tab and
     ~47px standalone on a notched device. */
  .brandbar { padding: calc(18px + env(safe-area-inset-top)) 20px 0;
              display: flex; align-items: center; justify-content: space-between; }
  #appbar { display: none; }
  #appbar.on { display: flex; }
  /* Mark and wordmark are separate images, not one baked strip: locked
     together the text is whatever size the artwork chose, which came out small
     and low against the ear of wheat.
     Sized by CAP HEIGHT, not by image height. brand/wordmark-lobster.png is
     padded symmetrically about its CAP CENTRE — the acute over «о» sits higher
     than the descender under «р» reaches, so centring raw ink would tilt the
     word downward — which is why align-items:center needs no nudge.
     Caps are 0.68 of the mark and the asset is 1.675 canvases per cap:
     36 × 0.68 × 1.675 = 41. Re-derive both if the asset is re-exported. */
  /* gap is 0.084 of the mark height, matching the landing page's lockup
     (3.84px against a 46px mark); at 10px the corn and the word read as two
     separate objects rather than one logo */
  /* a button, not a decoration: tapping the logo is the one gesture people try
     without being told, and it went nowhere. Button defaults stripped so it
     stays visually identical. */
  .logo { display: flex; align-items: center; gap: 3px; background: none;
          border: 0; padding: 0; cursor: pointer; font: inherit;
          -webkit-tap-highlight-color: transparent; transition: opacity .12s; }
  .logo:active { opacity: .6; }
  .logo .m { height: 36px; width: auto; display: block; }
  .logo .w { height: 41px; width: auto; display: block; }
  .searchbox { margin: 14px 20px 0; background: var(--sunken); border-radius: 14px;
               padding: 11px 16px; display: flex; align-items: center; gap: 10px;
               color: var(--faint); font-size: 14px; font-weight: 600; border: 0;
               width: calc(100% - 40px); text-align: left; cursor: pointer;
               font-family: inherit; }
  .searchbox .ico { color: var(--faint); }
  /* the search sort row: two mutually exclusive chips, relevance is default.
     Quiet by design — the row must read as an option, not a filter panel. */
  .sortrow { display: flex; gap: 8px; margin: 10px 20px 0; }
  .sortchip { border: 0; border-radius: 999px; padding: 7px 14px; font-size: 13px;
              font-weight: 700; font-family: inherit; cursor: pointer;
              background: var(--sunken); color: var(--muted); }
  .sortchip.on { background: var(--green-tint); color: var(--green-dk); }
  /* the field on the search screen is a real input, styled to match the button
     that leads to it, so tapping one does not visibly change the control */
  input.searchbox { color: var(--ink); cursor: text; -webkit-appearance: none;
                    appearance: none; outline: 0; padding-right: 48px; }
  input.searchbox:focus { background: var(--surface); box-shadow: 0 0 0 2px var(--green-tint); }
  input.searchbox::placeholder { color: var(--faint); font-weight: 600; }
  /* the native one is a tiny grey blob that appears only while focused and is
     styled differently on every platform; ours is always the same */
  input.searchbox::-webkit-search-cancel-button { -webkit-appearance: none; }
  /* The wrapper carries the margin and the input fills it, so the wrapper's box
     IS the field's box. While the input kept its own 14px top margin, the
     wrapper was 14px taller than the field and `top: 50%` put the clear button
     2px from the top edge and 16px from the bottom — visibly riding high. */
  .searchwrap { position: relative; margin: 14px 20px 0; }
  .searchwrap input.searchbox { margin: 0; width: 100%; }
  /* 16px inside the field, matching the padding the placeholder sits at on the
     left. At 10px it cleared the border by less than the 14px corner radius
     curves inward, so it read as hanging off the edge. */
  .searchwrap .clear { position: absolute; right: 16px; top: 50%;
              transform: translateY(-50%); width: 24px; height: 24px; border: 0;
              padding: 0; border-radius: 50%; background: #DCDCD0; color: #fff;
              display: none; align-items: center; justify-content: center;
              cursor: pointer; -webkit-tap-highlight-color: transparent; }
  .searchwrap .clear.on { display: flex; }
  .searchwrap .clear:active { background: #C9C9BC; }
  .scan-cta { margin: 14px 20px 0; background: var(--green); border-radius: 22px;
              padding: 18px 20px; display: flex; align-items: center; gap: 16px;
              box-shadow: var(--shadow-lg); border: 0; width: calc(100% - 40px);
              text-align: left; font-family: inherit; cursor: pointer; }
  .scan-cta .glyph { width: 58px; height: 58px; border-radius: 16px;
                     background: rgba(255,255,255,.18); display: flex;
                     align-items: center; justify-content: center; gap: 3px; flex-shrink: 0; }
  .scan-cta .glyph i { display: block; background: #fff; border-radius: 2px; height: 28px; }
  .scan-cta .tx { flex: 1; }
  .scan-cta .tx b { display: block; font-size: 18px; font-weight: 900; color: #fff; }
  .scan-cta .tx span { font-size: 13px; color: rgba(255,255,255,.85); }
  .scan-cta .go { width: 30px; height: 30px; border-radius: 50%;
                  background: rgba(255,255,255,.22); display: flex; align-items: center;
                  justify-content: center; color: #fff; font-weight: 900; flex-shrink: 0; }
  .sec-head { margin: 18px 20px 0; display: flex; align-items: baseline;
              justify-content: space-between; }
  .sec-head b { font-size: 17px; font-weight: 800; color: var(--ink); }
  .sec-head a { font-size: 13px; font-weight: 700; color: var(--green); text-decoration: none; }
  .empty { margin: 24px 20px; text-align: center; color: var(--muted); font-size: 14px;
           line-height: 1.5; }
  /* Only where the app has nothing to show — an empty history, a search that
     found nothing. Never beside a score: the verdict has to read as a
     measurement, and a cartoon next to «13 · погано» makes it a game. Assets
     are 2x of the drawn size and live outside brand/mascots/, which the phone
     bundle excludes wholesale. */
  .empty img { display: block; width: auto; height: 132px; margin: 8px auto 16px; }
  .backlink { display: inline-flex; align-items: center; font-size: 16px;
              color: var(--ink-2); font-weight: 700; text-decoration: none;
              margin: 2px 0 12px; padding: 6px 2px 6px 0; line-height: 1.3;
              max-width: 100%; overflow: hidden; text-overflow: ellipsis;
              white-space: nowrap; }
  /* The sheet is the whole screen now, not a card pushed to the bottom of a
     dark one: it opens over a tab and scrolls, so it starts at the top. */
  .sheet { background: var(--surface); color: var(--ink); flex: 1;
           padding: calc(20px + env(safe-area-inset-top)) 20px 20px; }
  /* The product photo is the identity anchor on a non-food card — «yes, this is
     the thing you scanned» — so show it a little larger than the 64px list
     thumbnail. The sheet keeps flex:1 so the button sits just above the menu,
     not floating; the overlay tracks the visible viewport (dvh) so that bottom
     stays above the browser toolbar. */
  .sheet.nf .prodhead img { width: 88px; height: 88px; }
  /* A non-food name can run a full sentence («Батончик з ананасом, морквою та
     арахісом…»). The non-food card is a fixed stack — name, mascot, message,
     report and «Сканувати наступний» buttons — so an unclamped name pushes the
     button off the bottom. Cap the non-food name at three lines. */
  .sheet.nf .prodhead h2 { display: -webkit-box; -webkit-line-clamp: 3;
                           -webkit-box-orient: vertical; overflow: hidden; }
  .sheet h2 { font-size: 19px; font-weight: 900; margin-bottom: 8px; overflow-wrap: anywhere; }
  .sheet p { color: var(--ink-2); font-size: 15px; line-height: 1.45; }
  .btn { display: block; width: 100%; background: var(--green); color: #fff; border: 0;
         border-radius: 14px; padding: 14px; font-size: 16px; font-weight: 900;
         margin-top: 12px; font-family: inherit; cursor: pointer; }
  .btn.ghost { background: var(--sunken); color: var(--ink-2); font-weight: 700; }
  /* Share used to close the sheet, after the склад, where almost nobody
     scrolled far enough to see it. It now sits in the sheet's top row. */
  /* The sheet's top row: back on the left, share on the right. Share is in the
     row even when there is no back link (a scan opens with none), so it is
     always in the same place. The back link gives up width, never the icon. */
  .sheet-top { display: flex; align-items: center; justify-content: space-between;
    gap: 12px; margin: 2px 0 12px; min-height: 40px; }
  .sheet-top .backlink { margin: 0; min-width: 0; }
  .sheet-top .sharebtn { flex: none; margin-left: auto; width: 40px; height: 40px;
    padding: 0; border: 0; border-radius: 50%; cursor: pointer;
    display: grid; place-items: center; background: var(--sunken);
    color: var(--ink-2); -webkit-tap-highlight-color: transparent; }
  .sheet-top .sharebtn:active { filter: brightness(.94); }
  /* «Повідомити про проблему» is a low-emphasis action everywhere it appears —
     a plain underlined link, never a button. Matches the product sheet's. */
  .report-link { display: block; width: auto; margin: 10px auto 0; padding: 6px;
    background: none; border: 0; color: var(--muted); font-size: 13.5px;
    font-weight: 700; text-decoration: underline; text-underline-offset: 3px;
    text-align: center; cursor: pointer; }
  .ico { display: block; flex-shrink: 0; }

  /* ---- score: the visual anchor of the screen ---- */
  .score-row { display: flex; align-items: center; gap: 16px; margin: 16px 0 16px; }
  .scorewrap { position: relative; width: 100px; height: 100px; flex-shrink: 0; }
  .scorewrap svg { display: block; }
  .scoreval { position: absolute; inset: 0; display: flex; flex-direction: column;
              align-items: center; justify-content: center; font-weight: 800;
              font-size: 32px; line-height: 1; letter-spacing: -.5px; }
  .scoreval small { font-size: 11px; color: var(--faint); font-weight: 600; margin-top: 3px;
                    letter-spacing: 0; }
  .band { font-size: 20px; font-weight: 700; line-height: 1.2; }

  /* ---- nutrients: value + proportional bar against the traffic-light bands ---- */
  .nrow { padding: 8px 11px 9px; background: var(--inset); border-radius: 12px;
          margin-bottom: 6px; font-size: 14px; }
  .nhead { display: flex; align-items: center; gap: 9px; }
  .nname { flex: 1; color: var(--ink); font-weight: 600; }
  .nval { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
  .nbarwrap { display: flex; align-items: center; gap: 9px; margin: 6px 0 0 29px; }
  .nbar { position: relative; flex: 1; height: 7px; border-radius: 4px;
          background: var(--line); overflow: hidden; }
  .nbar i { display: block; height: 100%; border-radius: 4px; min-width: 3px; }
  .nbar b { position: absolute; top: 0; bottom: 0; width: 1px;
            background: rgba(0,0,0,.22); }
  .nword { font-size: 12px; font-weight: 700; min-width: 74px; text-align: right; }
  .nmiss { display: flex; align-items: center; gap: 9px; padding: 9px 11px;
           background: var(--inset); border-radius: 12px; font-size: 13px; color: var(--faint); }

  /* ---- allergens: icon tiles, one colour per scope ---- */
  .alrg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
               gap: 6px; margin-bottom: 7px; }
  .alrg { display: flex; align-items: center; gap: 10px; padding: 9px 11px 9px 16px;
          border-radius: 12px; font-size: 14px; border: 1.5px solid transparent;
          position: relative; overflow: hidden; }
  .alrg.has { background: #FDECEA; color: #8A1C12; border-color: #F0C6C0; }
  .alrg.may { background: #FFF6E5; color: #7A5C00; border-color: #EFDCA6; }
  .alrg.no  { background: #EFF5F0; color: #3D6B45; border-color: #D6E4D9; }
  .alrg.ok  { background: #EAF6EC; color: #1B5E20; border-color: #C8E3CC;
              grid-column: 1 / -1; }
  /* Scope must survive losing the hue, and one cue is enough to do it: the
     left edge bar is unbroken for "у складі" and broken into segments for
     "може містити", with no bar at all for "не містить". Certainty stays a
     separate axis (dashed border + "?"), so all combinations remain readable. */
  .alrg::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; }
  .alrg.has::before { background: currentColor; }
  .alrg.may::before { background: repeating-linear-gradient(180deg,
                        currentColor 0 5px, transparent 5px 10px); }
  .alrg .tx { flex: 1; min-width: 0; }
  .alrg .w { font-weight: 700; line-height: 1.2; display: block; }
  .alrg .n { display: block; font-size: 11.5px; font-weight: 400;
             opacity: .85; margin-top: 2px; line-height: 1.25; }
  /* most tiles carry no caption now — only «можливо: X» does, so the empty
     span must not reserve a line and leave the label floating high */
  .alrg .n:empty { display: none; }
  /* "possible" = the label never named the source; must never read as confirmed */
  .alrg.maybe { border-style: dashed; }
  .alrg.maybe .ico { stroke-dasharray: 3 2.4; }
  .iw { position: relative; flex-shrink: 0; line-height: 0; }
  .alrg.maybe .iw::after { content: '?'; position: absolute; right: -4px; bottom: -4px;
                   width: 14px; height: 14px; border-radius: 50%; color: #fff;
                   font-size: 10px; font-weight: 800; line-height: 14px;
                   text-align: center; background: var(--faint); }
  .alrg.has.maybe .iw::after { background: #8A1C12; }
  .alrg.may.maybe .iw::after { background: #7A5C00; }
  .alrg-note { font-size: 12.5px; color: var(--muted); margin: 0 0 5px 2px; }

  /* ---- concerns ---- */
  .chips { display: flex; flex-wrap: wrap; gap: 6px; }
  .chip { display: inline-flex; align-items: center; gap: 6px; background: #F5F0E6;
          color: #5A4A22; font-size: 12.5px; font-weight: 600;
          border-radius: 20px; padding: 6px 12px 6px 9px; }
  .chip .ico { color: #A97B29; }

  /* ---- additives: risk colour carries the meaning ---- */
  .add-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr));
              gap: 6px; }
  .add-cell { background: var(--inset); border-radius: 10px; padding: 8px 10px;
              border-left: 4px solid var(--line); }
  .add-cell .c { display: flex; align-items: center; gap: 7px;
                 font-weight: 700; font-size: 13.5px; color: var(--ink); }
  .add-cell .rl { margin-left: auto; font-size: 10.5px; font-weight: 700;
                  text-transform: lowercase; }
  .add-cell { cursor: pointer; }
  .add-cell .nm { font-size: 12px; color: var(--muted); line-height: 1.3; margin-top: 3px; }
  .add-cell .add-info { display: none; font-size: 13px; line-height: 1.4;
             color: var(--ink); font-weight: 600; margin-top: 8px;
             padding-top: 8px; border-top: 1px solid var(--inset-ln); }
  .add-cell.open .add-info { display: block; }
  .dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
  .ing { font-size: 13px; color: var(--muted); line-height: 1.45; margin-top: 6px; }
  .ingmore { display: inline-block; margin-top: 6px; font-size: 13px;
             font-weight: 600; color: var(--green-dk); text-decoration: none; }
  /* ---- recommendation: three states, three different messages ---- */
  .alts { display: flex; gap: 10px; overflow-x: auto; padding: 4px 2px 6px;
          scroll-snap-type: x proximity; }
  /* heading of the full-shelf list that «Показати більше» opens */
  /* 20px, matching .hitem / #searchInput / #sortRow. At 2px the heading hung
     18px outside every other element on the alternatives page and read as
     touching the screen edge. */
  .alts-head { font-weight: 700; font-size: 15px; margin: 2px 20px 14px; }
  /* the «Показати більше» link at the RIGHT END of the strip, centred in it */
  /* the trailing «→ Усі» tile at the end of the strip, revealed on scroll */
  .alts-all { flex: 0 0 auto; align-self: center; display: flex;
    flex-direction: column; align-items: center; gap: 9px;
    padding: 0 22px 0 10px; color: var(--green); text-decoration: none;
    cursor: pointer; -webkit-tap-highlight-color: transparent; }
  .alts-all .ci { width: 48px; height: 48px; border-radius: 50%;
    background: var(--green-soft); display: flex; align-items: center;
    justify-content: center; }
  .alts-all b { font-size: 12px; font-weight: 600; }
  /* the card opens that product, so it has to look pressable — a tile that
     reacts to nothing reads as a picture, and nobody taps a picture */
  .alt { min-width: 132px; max-width: 132px; background: var(--inset); border-radius: 14px;
         padding: 9px 9px 10px; scroll-snap-align: start; cursor: pointer;
         -webkit-tap-highlight-color: transparent; transition: transform .12s, background .12s;
         display: flex; flex-direction: column; }
  .alt:active { transform: scale(.97); background: var(--line); }
  /* first card of a strip whose products come from another category: the
     same size as a product card so it reads as part of the list, but outlined
     and not tappable so it never reads as a product */
  .alt-note { min-width: 132px; max-width: 132px; border-radius: 14px; padding: 12px 11px;
              border: 1.5px dashed var(--line); color: var(--ink-2); font-size: 12.5px;
              line-height: 1.35; display: flex; flex-direction: column; gap: 8px;
              align-items: center; justify-content: center; text-align: center;
              scroll-snap-align: start; }
  .alt-note .ico { color: var(--faint); }
  .alt-note .go { color: var(--ink-2); }
  .alt img, .alt .noimg { width: 100%; height: 76px; object-fit: contain;
         background: #fff; border-radius: 10px; }
  .alt .noimg { display: flex; align-items: center; justify-content: center; color: var(--line); }
  .alt .t { font-size: 11.5px; line-height: 1.3; color: var(--ink-2);
            margin: 6px 0 auto; min-height: 30px; overflow: hidden; }
  /* the same ring as the verdict at the top of the sheet, at a third the size —
     one visual language for "score", never a second one for the strip */
  .alt .sr { display: flex; align-items: center; gap: 7px; margin-top: 8px; }
  .ringsm { position: relative; width: 38px; height: 38px; flex-shrink: 0; }
  .ringsm .v { position: absolute; inset: 0; display: flex; align-items: center;
               justify-content: center; font-weight: 800; font-size: 13px; }
  .alt .gain { font-size: 11.5px; font-weight: 800; color: #1B8A4C;
               background: #EAF6EC; border-radius: 20px; padding: 3px 7px; }
  .alt .pr { font-size: 11px; color: var(--faint); margin-top: 6px; }
  /* best in class is the best news the app can deliver, so it gets the same
     green as a cleared allergen check, not an empty strip */
  .rec-note { display: flex; gap: 10px; align-items: flex-start; border-radius: 12px;
              padding: 11px 12px; font-size: 14px; background: var(--inset); color: var(--ink-2); }
  .rec-note .ico { color: var(--faint); margin-top: 1px; flex-shrink: 0; }
  .rec-note.top { background: #EAF6EC; color: #1B5E20; border: 1.5px solid #C8E3CC; }
  .rec-note.top .ico { color: #1B8A4C; }
  .rec-note b { display: block; }
  .rec-note .n { display: block; font-size: 12.5px; opacity: .85;
                 margin-top: 3px; line-height: 1.35; }
  .photos { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
  .photos .shot { position: relative; width: 78px; height: 78px; border-radius: 10px;
                  overflow: hidden; background: var(--inset); flex-shrink: 0; }
  .photos .shot img { width: 100%; height: 100%; object-fit: cover; }
  .photos .shot .x { position: absolute; top: 2px; right: 2px; width: 20px; height: 20px;
                     border-radius: 50%; background: rgba(0,0,0,.6); color: #fff;
                     font-size: 14px; line-height: 20px; text-align: center; cursor: pointer; }
  .slot { flex: 1; aspect-ratio: 3/4; border: 2px dashed var(--line); border-radius: 10px;
          display: flex; align-items: center; justify-content: center; font-size: 12px;
          color: var(--faint); text-align: center; padding: 4px; overflow: hidden; position: relative; }
  .slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .slot input { position: absolute; inset: 0; opacity: 0; }
  .prodhead { display: flex; gap: 12px; align-items: center; }
  /* the text column must be allowed to shrink, or a long unbreakable title
     (POS names with no spaces, e.g. ЙОГУРТ3%ПОЛУНИЧНЕМОРОЗИВО120Г) forces it
     wider than the row and overflows past the image */
  .prodhead > div { min-width: 0; }
  .prodhead img { width: 64px; height: 64px; object-fit: contain; background: var(--inset); border-radius: 10px; cursor: zoom-in; }
  .prodhead .addphoto { width: 64px; height: 64px; border-radius: 10px; flex: none;
    background: var(--green-soft); border: 1.5px dashed var(--green); color: var(--green);
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 2px; cursor: pointer; font: inherit; padding: 0; }
  .prodhead .addphoto b { font-size: 20px; font-weight: 700; line-height: 1; }
  .prodhead .addphoto span { font-size: 9.5px; font-weight: 700; letter-spacing: .2px; }
  /* Tap the product photo to see it large. A dimmed backdrop, the image
     centred, tap anywhere to close. Hidden until opened; fades in. */
  .lightbox { position: fixed; inset: 0; z-index: 40; display: none;
              align-items: center; justify-content: center; padding: 24px;
              background: rgba(10,12,8,.86); cursor: zoom-out;
              -webkit-tap-highlight-color: transparent; }
  .lightbox.on { display: flex; animation: lbfade .15s ease-out; }
  @keyframes lbfade { from { opacity: 0 } to { opacity: 1 } }
  .lightbox img { max-width: 92vw; max-height: 88vh; object-fit: contain;
                  border-radius: 14px; background: #fff; }
  @media (prefers-reduced-motion: reduce) { .lightbox.on { animation: none } }

  /* First unknown product only: what the camera is about to ask for, before it
     asks. A scan that ends in "photograph this" is the one moment a first-time
     user has no idea what is wanted, and a label shot that misses half the
     panel comes back as «недостатньо даних» — so the two-photo case is said
     up front rather than discovered. Shown once ever (localStorage). */
  .tipwrap { position: fixed; inset: 0; z-index: 45; display: none;
             align-items: center; justify-content: center; padding: 22px;
             background: rgba(10,12,8,.88); -webkit-tap-highlight-color: transparent; }
  .tipwrap.on { display: flex; animation: lbfade .18s ease-out; }
  .tipcard { background: var(--surface); border-radius: 22px; width: 100%;
             max-width: 360px; padding: 22px 20px 20px; text-align: center;
             animation: tippop .3s cubic-bezier(.2,.9,.3,1.08);
             max-height: 88vh; overflow-y: auto;
             box-shadow: 0 24px 60px -20px rgba(0,0,0,.5); }
  @keyframes tippop { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } }
  .tipcard h3 { font-size: 19px; font-weight: 900; color: var(--ink); margin-bottom: 8px; }
  /* The title is centred, the prose is not: two ragged-centred paragraphs have
     no shared left edge and read as one grey block. The instruction leads in
     ink with the action bolded; the caveat below is demoted so the two are not
     competing for the same attention. */
  .tipcard > p { text-align: left; }
  .tip-lead { font-size: 15px; line-height: 1.5; color: var(--ink-2); margin-top: 12px; }
  .tip-lead b { color: var(--ink); font-weight: 800; }
  .tip-note { font-size: 13px; line-height: 1.45; color: var(--muted);
              margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
  .tipcard .btn { margin-top: 18px; }

  /* The landing page's «Немає в базі?» graphic, ported as-is: a tub of hummus
     back-turned in a viewfinder with its real panel, ingredient line and
     barcode, photographed — then the score lands. Bounded to 3 plays and kept
     to transform/opacity: this card sits over the LIVE camera, where an
     infinite animation is the documented overheating bug. */
  .tipviz { position: relative; width: 100%; aspect-ratio: 402/250; border-radius: 14px;
            background: #fff; border: 1px solid var(--line); overflow: hidden;
            display: flex; align-items: center; justify-content: center; margin: 16px 0 4px; }
  .tipviz .pack { position: relative; width: 66%; transform: rotate(-3deg) scale(.86); text-align: left;
                  transform-origin: center; animation: tipfocus 3.4s ease-in-out 3;
                  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                  color: #3a4032; }
  .tipviz .pk-lid { height: 12px; width: 110%; margin: 0 -5%; border-radius: 9px 9px 4px 4px;
                    background: linear-gradient(180deg,#eae6d9 0%,#ddd8c9 55%,#c7c2b2 100%);
                    box-shadow: inset 0 -2.5px 3px rgba(0,0,0,.1), inset 0 2px 2px rgba(255,255,255,.6) }
  .tipviz .pk-body { position: relative; padding: 9px 14px 15px; overflow: hidden;
                     border-radius: 0 0 12px 12px;
                     clip-path: polygon(0 0,100% 0,90% 100%,10% 100%);
                     background: linear-gradient(100deg,#e4e0d2 0%,#faf8f1 20%,#f6f4ec 74%,#dcd8c9 100%) }
  .tipviz .pk-h { font-size: 9px; font-weight: 800; color: #23291b; letter-spacing: .01em;
                  border-bottom: 1.5px solid #23291b; padding-bottom: 3px }
  .tipviz .pk-h em { font-style: normal; font-weight: 600; color: #84897a; font-size: 8px }
  .tipviz .pk-t { width: 100%; border-collapse: collapse; font-size: 8px; line-height: 1; margin-top: 1px }
  .tipviz .pk-t td { padding: 3.2px 0; border-bottom: .5px solid #d8dccb }
  .tipviz .pk-t td:last-child { text-align: right; font-weight: 700;
                                font-variant-numeric: tabular-nums; white-space: nowrap }
  .tipviz .pk-skl { font-size: 7px; line-height: 1.45; color: #6b7160; margin-top: 6px }
  .tipviz .pk-skl b { color: #3a4032 }
  /* real bars, whole-pixel widths, not a fractional-px gradient: the gradient's
     1.5px bands smear to grey once the device pixel ratio scales them. */
  .tipviz .pk-bc { height: 13px; margin: 6px 7% 0 auto; display: flex; gap: 1px;
                   justify-content: flex-end }
  .tipviz .pk-bc i { height: 100%; background: #23291b; flex: none }
  .tipviz .fc { position: absolute; width: 26px; height: 26px; border: 3px solid var(--green); z-index: 2 }
  .tipviz .f1 { top: 12px; left: 12px; border-right: 0; border-bottom: 0; border-radius: 7px 0 0 0 }
  .tipviz .f2 { top: 12px; right: 12px; border-left: 0; border-bottom: 0; border-radius: 0 7px 0 0 }
  .tipviz .f3 { bottom: 12px; left: 12px; border-right: 0; border-top: 0; border-radius: 0 0 0 7px }
  .tipviz .f4 { bottom: 12px; right: 12px; border-left: 0; border-top: 0; border-radius: 0 0 7px 0 }
  .tipviz .flash { position: absolute; inset: 0; background: #fff; opacity: 0; z-index: 3;
                   pointer-events: none; animation: tipflash 3.4s ease-in-out 3 }
  /* The pack stays in frame the whole time: it holds, focuses in, the shutter
     fires (flash), then holds again. No score, no disappearing act. */
  @keyframes tipfocus {
    0%,64%  { transform: rotate(-3deg) scale(.86) }
    72%     { transform: rotate(-3deg) scale(.80) }
    80%,100%{ transform: rotate(-3deg) scale(.86) } }
  @keyframes tipflash  { 0%,70%,100% { opacity: 0 } 75% { opacity: .85 } 82% { opacity: 0 } }
  @media (prefers-reduced-motion: reduce) {
    .tipwrap.on, .tipcard, .tipviz .pack, .tipviz .flash { animation: none } }
  /* the pack size, pulled out of the title: a fact about the product, not part
     of its name, and the one number a shopper compares two packs on */
  .prodhead .size { display: inline-block; margin-top: 4px; padding: 2px 8px;
                    background: var(--inset); border-radius: 20px;
                    font-size: 12px; font-weight: 600; color: var(--muted); }
  .alt .sz { font-size: 10.5px; color: var(--faint); margin-top: 2px; }
  /* ── the ask ─────────────────────────────────────────────────────────────
     Photographing a label is a favour the user does us, and it was dressed as
     a yellow warning — the same styling as «завеликий вміст цукру». A warning
     is something you dismiss. This is an invitation, so it is green, it says
     which side of the pack and which two blocks on it, and it shows them. */
  .misshalf { background: var(--inset); border-radius: 14px; padding: 18px;
              margin: 14px 0; text-align: center; }
  /* The capture-failed sheet: centred, a big amber cue, and body text at full
     ink and 17px so it is the loudest thing on the screen, not grey footnotes
     over a dark viewfinder. */
  .sheet.retry { text-align: center; padding-top: 30px; }
  .sheet.retry .retry-ic { display: flex; justify-content: center; color: #E08A1E;
              margin-bottom: 12px; }
  .sheet.retry h2 { font-size: 23px; }
  .sheet.retry p { color: var(--ink); font-size: 17px; line-height: 1.4;
              max-width: 32ch; margin: 0 auto 4px; }
  .sheet.retry .btn { margin-top: 14px; }
  .misshalf b { display: block; font-size: 18px; font-weight: 800; color: var(--ink);
                line-height: 1.35; margin-bottom: 14px; }
  .capture { background: var(--green-soft); border: 1.5px solid #DEEACB;
             border-radius: 18px; padding: 16px; margin: 12px 0; }
  .capture h3 { font-size: 16px; font-weight: 900; color: #2F5B12;
                line-height: 1.3; margin-bottom: 5px; }
  .capture p { font-size: 13.5px; line-height: 1.45; color: #4C6B34; }
  .capture .pack { display: flex; gap: 10px; margin: 13px 0 4px; }
  .capture .face { flex: 1; background: #fff; border: 1.5px solid #DEEACB;
                   border-radius: 12px; padding: 9px 10px 10px; }
  /* When only one of the two is missing, only one is drawn — a dimmed card is
     still a card, spending half the graphic to say «not this one». Narrowed
     and centred so a lone block does not stretch into a banner. */
  .capture .pack.one { justify-content: center; }
  .capture .pack.one .face { flex: 0 1 62%; }
  .capture .face b { display: block; font-size: 11.5px; font-weight: 900;
                     color: #2F5B12; margin-bottom: 6px; }
  /* a few grey bars standing in for text, and a mini table: enough for the
     shape to be recognised as «the ingredients paragraph» and «the panel» */
  .capture .face i { display: block; height: 4px; border-radius: 2px;
                     background: #DDE7CE; margin-bottom: 4px; }
  .capture .face .tb { display: grid; grid-template-columns: 1fr 26px;
                       gap: 3px 6px; }
  .capture .face .tb i { margin: 0; }
  .capture .btn { margin-top: 12px; }
  .capture .note { font-size: 12.5px; color: #6C8451; margin-top: 9px;
                   line-height: 1.4; text-align: center; }
  /* the fallback for a refused or hopeless camera — a quiet way out, in the
     app's own green. Left to the UA it rendered as a blue underlined link,
     which is a colour that appears nowhere else in this app. */
  .capture .note a { color: inherit; font-weight: 800;
                     text-underline-offset: 3px; }
  /* ── form fields ─────────────────────────────────────────────────────────
     16px on the inputs is not a style choice: below it, iOS Safari zooms the
     whole page in when the field takes focus and never zooms back out. */
  .field { display: block; margin-bottom: 10px; }
  .field > span { display: block; font-size: 12.5px; font-weight: 700;
                  color: var(--muted); margin-bottom: 5px; }
  .field input { width: 100%; background: var(--inset); color: var(--ink);
                 border: 1.5px solid var(--line); border-radius: 12px;
                 padding: 12px 14px; font-size: 16px; font-family: inherit;
                 outline: 0; -webkit-appearance: none; appearance: none; }
  .field input:focus { border-color: var(--green); background: var(--surface); }
  .fieldgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
  .btn:disabled { background: var(--line); color: var(--faint); cursor: default; }
  .formerr { font-size: 13px; font-weight: 700; color: #C0392B; margin: 2px 0 6px; }
  /* alcohol / tobacco: no score, and no colour that could be read as a verdict */
  .unrated { background: var(--inset); border: 1.5px solid var(--inset-ln);
             border-radius: 18px; padding: 15px 16px; margin: 16px 0 14px; }
  .unrated .hd { display: flex; align-items: center; gap: 9px; margin-bottom: 7px; }
  .unrated .hd .ico { color: var(--muted); flex-shrink: 0; }
  .unrated .hd b { font-size: 16px; font-weight: 900; color: var(--ink); }
  .unrated p { font-size: 14px; line-height: 1.5; color: var(--ink-2); }
  .unrated .foot { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--inset-ln);
                   font-size: 12.5px; color: var(--muted); }
  .warn { display: flex; align-items: flex-start; gap: 10px;
          background: #FFF8E1; border: 1px solid #FFE082; border-radius: 12px;
          padding: 12px; margin: 10px 0; font-size: 14px; color: #7A5C00; }
  .warn .ico { color: #C58B00; margin-top: 1px; }
  .warn.sm { font-size: 13px; padding: 10px 11px; margin: 0 0 8px; }
  /* The reason is the verdict's own subtitle — it sits in the score block next
     to the ring, under the band, sharing the score's identity rather than being
     a separate card. Legible and bold, but part of the same thing. */
  .verdict { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
  .why { font-size: 15px; line-height: 1.35; font-weight: 700; }
  .why .ico { color: var(--faint); margin-top: 1px; }
  .section-t { display: flex; align-items: center; gap: 8px;
               font-weight: 700; margin: 16px 0 7px; font-size: 15px; }
  .section-t .ico { color: var(--faint); }
  #sheet-holder { display: none; flex: 1; flex-direction: column; }
  #sheet-holder.on { display: flex; }
  /* Opened from a scan, the sheet is not another screen — it is the peek card
     carrying on upward. It covers the viewfinder rather than replacing it, so
     the camera is still there underneath when it slides back down, and the
     gesture reads as one continuous movement instead of a page change. */
  /* The product sheet, opened from a scan: a panel that slides up over the
     live viewfinder and back down again. Two states and one transition — the
     class says which, CSS does the moving. */
  /* Height in dvh, not bottom:0. bottom:0 on this absolutely-positioned box
     resolves against the LAYOUT viewport, which ignores the browser's bottom
     toolbar, so the box (and the «Сканувати наступний» button at its foot) ran
     under the toolbar — while #tabbar, being position:fixed, tracked the VISIBLE
     viewport and stayed above it, leaving a mismatch. Sizing by 100dvh makes the
     overlay's bottom track the visible viewport too, in step with the menu. */
  #sheet-holder.ov { position: absolute; top: max(76px, 10vh); left: 0; right: 0;
              /* svh, not dvh: with the browser toolbar up, dvh can report a
                 taller height than where the fixed #tabbar actually sits, so the
                 overlay ran a few px past the menu and the button overlapped it.
                 svh is the toolbar-expanded (smallest) height — exactly this
                 state — so the overlay's bottom lands on the menu, not past it. */
              height: calc(100vh - max(76px, 10vh));
              height: calc(100svh - max(76px, 10vh)); z-index: 6;
              overflow-y: auto; overscroll-behavior: contain;
              /* No -webkit-overflow-scrolling: touch. It is a no-op on iOS 13+
                 (momentum scrolling is the default), but it still forces this
                 rounded, sticky-containing scroll box onto its own accelerated
                 layer, and iOS then fails to repaint parts of it mid-scroll —
                 whole sheet sections dropped out and popped back. */
              background: var(--bg);
              border-radius: 22px 22px 0 0; box-shadow: 0 -8px 30px rgba(0,0,0,.3);
              padding-bottom: var(--tabh, calc(100px + env(safe-area-inset-bottom)));
              transform: translateY(100%);
              transition: transform .3s cubic-bezier(.32,.72,0,1); }
  #sheet-holder.ov.up { transform: none; }
  /* The overlay already opens below the notch (top: 76px), so the sheet's
     safe-area-inset-top padding (meant for a full-screen sheet) double-counts
     and leaves a big gap above the photo/title. Drop it in overlay mode. */
  #sheet-holder.ov .sheet { padding-top: 16px; }
  #sheet-holder.ov.grabbed { transition: none; }
  #sheet-holder.ov::before { content: ""; position: sticky; top: 0; z-index: 2;
              display: block; width: 40px; height: 4px; border-radius: 2px;
              background: #D8D9CC; margin: 10px auto 6px; flex: none;
              box-shadow: 0 -10px 0 10px var(--bg); }
  @media (prefers-reduced-motion: reduce) { #sheet-holder.ov { transition: none } }
  #closeBtn { display: none; width: calc(100% - 40px); margin: 14px 20px 18px;
              background: var(--sunken); color: var(--ink-2); border: 0;
              border-radius: 14px; padding: 16px; font-size: 15px; font-weight: 800;
              font-family: inherit; cursor: pointer; }

  /* ── bottom navigation: four destinations and the one verb ───────────────
     Scanning is not a peer of Історія or Профіль — it is the thing the app is
     for, and burying it in a row of equal tabs made it one tap of five. The
     centre button breaks the row on purpose. */
  #tabbar { position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
            max-width: 480px; margin: 0 auto; display: flex; align-items: flex-end;
            background: rgba(250,250,244,.97); backdrop-filter: blur(12px);
            border-top: 1px solid var(--line);
            padding: 10px 12px calc(14px + env(safe-area-inset-bottom)); }
  .tab { flex: 1; background: none; border: 0; padding: 0; font-family: inherit;
         display: flex; flex-direction: column; align-items: center; gap: 4px;
         color: var(--faint); font-size: 11px; font-weight: 700; cursor: pointer;
         -webkit-tap-highlight-color: transparent; }
  .tab .ico { color: currentColor; }
  .tab.on { color: var(--green); font-weight: 800; }
  /* Bigger than its neighbours because scanning is what the app is for, but
     INSIDE the bar. Raised out of it, the button sat on top of whatever the
     screen put lowest — the peek's «Детальніше» among them — and a control
     that covers other controls is not emphasis, it is an obstacle. */
  /* Bigger than its neighbours, labelled like them, and still not the thing
     that sets the bar's height. A 46px circle stacked over a label came to
     62px against the other tabs' 39px and pushed the bar to 96px; dropping the
     label got the height back but left the one unlabelled control in a row of
     labelled ones. The circle instead eats the bar's top padding — a 40px ring
     pulled up 6px sits 4px inside the top edge, so it never breaks out of the
     bar, and the whole tab comes to 47px for a 71px bar. */
  .tab.fab { flex: 1.2; }
  .tab.fab .ring { width: 34px; height: 34px; margin-top: -8px; border-radius: 50%;
                   background: var(--green);
                   box-shadow: 0 4px 12px rgba(111,168,63,.38); display: flex;
                   align-items: center; justify-content: center; gap: 2.5px;
                   transition: transform .12s; }
  .tab.fab:active .ring { transform: scale(.93); }
  .tab.fab .ring i { display: block; width: 2.5px; height: 14px; background: #fff;
                     border-radius: 1px; }
  .tab.fab span { color: var(--green); font-weight: 800; font-size: 11px;
                  margin-top: 1px; }
  .tab.fab .ring i:nth-child(2) { width: 4px; }
  .tab.fab .ring i:nth-child(3) { width: 2px; }
  .tab.fab .ring i:nth-child(4) { width: 3px; }
  /* The tab bar grows by the safe-area inset (~34px on a notched iPhone), so
     the space reserved for it has to grow too — otherwise the last item on
     every screen slides under it, and the raised centre button lands on top of
     whatever sits lowest. */
  /* 100px is the measured bar height (96px) plus a little; it must exceed it,
     or the bottom of every screen — and the peek — slides underneath. */
  /* The bar's real height, measured, not guessed. The guess was 100px for a
     bar that is 71 — 29px of dead page showing between the viewfinder and the
     menu on every phone. The calc() stays as the value used before the first
     measurement lands and if JS never runs at all. */
  #app { padding-bottom: var(--tabh, calc(100px + env(safe-area-inset-bottom))); }

  /* ── list row: history, search results, recent scans ─────────────────────── */
  .hitem { display: flex; gap: 12px; align-items: center; background: var(--surface);
           border-radius: 16px; padding: 11px 14px; margin: 0 20px 8px;
           text-align: left; width: calc(100% - 40px); border: 0;
           box-shadow: var(--shadow); font-family: inherit; cursor: pointer;
           -webkit-tap-highlight-color: transparent; transition: transform .12s; }
  .hitem:active { transform: scale(.985); }
  .hitem img, .hitem .ph { width: 42px; height: 42px; object-fit: contain;
               background: var(--inset); border-radius: 12px; flex-shrink: 0; }
  .hitem .ph { display: flex; align-items: center; justify-content: center;
               color: var(--faint); }
  .hitem .t { flex: 1; min-width: 0; font-size: 14px; font-weight: 800;
              color: var(--ink); line-height: 1.3; overflow-wrap: anywhere; }
  /* block, or the caption runs on from the last word of the name */
  .hitem .d { display: block; font-size: 12px; font-weight: 600;
              color: var(--muted); margin-top: 2px; }
  .hitem .s { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
  .hitem .s b { font-size: 15px; font-weight: 900; color: var(--ink);
                min-width: 26px; text-align: right; }
  .spin { text-align: center; padding: 30px; color: var(--muted); font-weight: 700; }

  /* ── viewfinder (4b) ─────────────────────────────────────────────────────
     The only dark surface in the app. Corner brackets are white and the sweep
     line is the brand green: the amber in the mockup is the logo's wheat, and
     using it here would make the one warning colour mean "aim here" too. */
  /* min-height:0 or the flex item refuses to shrink below its contents: on a
     short screen the viewfinder grew, pushed #app past 100vh and carried the
     peek off the bottom of the display. overflow:hidden then crops the frame
     rather than the answer. */
  #scanUI { position: relative; background: var(--cam); overflow: hidden; min-height: 0; }
  #camwrap { position: absolute; inset: 0; }
  /* Hidden until it is genuinely playing. A <video> that has just been handed
     a stream is briefly laid out at the stream's own size rather than at the
     100%/cover this rule asks for, and on iOS that shows up as a small window
     of camera on a dark screen that then snaps to full bleed. Nothing is lost
     by waiting: --cam is behind it, which is what the viewfinder looks like
     while the camera is starting anyway. */
  /* ABSOLUTE, not a percentage of its parent. `height: 100%` only resolves
     against a parent with a definite height, and #camwrap gets its height from
     #scanUI, which is a flex item — on a cold start the WebView lays this out
     before the flex height is known, the percentage falls back to `auto`, and
     the element takes the STREAM's own size: a narrow portrait rectangle
     floating in the middle of a black screen, which then snaps to full bleed a
     moment later. Sized off `inset: 0` there is no percentage to resolve and
     no intermediate size to see. */
  #cam { position: absolute; inset: 0; width: 100%; height: 100%;
         object-fit: cover; background: var(--cam); display: block; }
  /* MOVED AWAY, not faded. Five attempts to hold this element back with
     `opacity: 0` all leaked the same half-sized frame, which is what it looks
     like when the property is not being honoured: on iOS a <video> carrying a
     MediaStream is composited by the system, and its layer does not reliably
     take opacity while it is starting up. A translate is the compositor's own
     language and cannot be quietly dropped. Two viewports is far enough that
     no rounding puts a pixel of it back on screen.
     Deliberately NOT display:none — that stops the element rendering, and a
     video iOS is not rendering is a video iOS may stop decoding, which would
     take videoWidth and the whole readiness test with it. */
  #cam:not(.live) { transform: translateX(-200vw); opacity: 0; }
  #cam.live { transition: opacity .12s linear; }
  /* Native barcode scan: the ML Kit plugin renders the camera BEHIND the
     WebView, so everything between it and the overlay must be see-through. The
     web app never sets .native-scan, so this is inert in the browser. */
  html.native-scan, html.native-scan body, html.native-scan #app,
  html.native-scan #scanUI, html.native-scan #camwrap { background: transparent !important; }
  html.native-scan #cam { display: none !important; }
  /* No frosted blur on the tab bar while the camera is live behind it: the
     backdrop-filter re-blurs the moving camera pixels under it every frame.
     At 97% opacity the blur was invisible anyway; the GPU cost was not. */
  html.native-scan #tabbar { backdrop-filter: none; }
  #overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
  /* flex:1, not height:100% — a percentage height against a flex-sized parent
     resolves to auto, so the column had no free space and `margin-top:auto`
     left the manual-entry link stranded under the frame instead of at the foot
     of the screen. */
  .cam-ui { position: relative; z-index: 2; display: flex; flex-direction: column;
            align-items: center; flex: 1; min-height: 0;
            padding-top: calc(22px + env(safe-area-inset-top)); }
  /* No backdrop-filter on anything over the live camera: a blur has to re-blur
     the camera pixels behind it every frame because they change every frame.
     Over live video it is the most expensive thing CSS can do, and it ran for
     as long as the scanner was open. A slightly denser tint reads the same. */
  .cam-pill { background: rgba(20,23,15,.8);
              color: #E8EBD9; font-size: 14px; font-weight: 700;
              padding: 8px 18px; border-radius: 999px; text-align: center;
              max-width: calc(100% - 40px); }
  .frame { position: relative; width: 264px; height: 170px; margin-top: 46px; }
  .frame i { position: absolute; width: 40px; height: 40px; }
  .frame i:nth-child(1) { top: 0; left: 0; border-top: 4px solid #fff;
      border-left: 4px solid #fff; border-top-left-radius: 18px; }
  .frame i:nth-child(2) { top: 0; right: 0; border-top: 4px solid #fff;
      border-right: 4px solid #fff; border-top-right-radius: 18px; }
  .frame i:nth-child(3) { bottom: 0; left: 0; border-bottom: 4px solid #fff;
      border-left: 4px solid #fff; border-bottom-left-radius: 18px; }
  .frame i:nth-child(4) { bottom: 0; right: 0; border-bottom: 4px solid #fff;
      border-right: 4px solid #fff; border-bottom-right-radius: 18px; }
  /* The sweeping aim line, restored. It was frozen on 2026-09-04 while chasing
     the overheating, but that was the wrong suspect: the CSS change alone did
     not stop the S8+ pinning at its throttle ceiling, and the decisive fix was
     gating ML Kit to one frame per 100 ms in the plugin (ANALYZE_INTERVAL_MS
     on Android, lastAnalyzedNs on iOS). That throttle carries the heat now.
     The line is what tells a user the scanner is alive, so it moves again.
     Animated on transform only, which the compositor can run without
     repainting the camera behind it. */
  .frame b { position: absolute; left: 16px; right: 16px; top: 50%; height: 2px;
             background: rgba(111,168,63,.95); box-shadow: 0 0 14px rgba(111,168,63,.8);
             animation: sweep 2.6s ease-in-out infinite; }
  @keyframes sweep { 0%,100% { transform: translateY(-64px) } 50% { transform: translateY(64px) } }
  @media (prefers-reduced-motion: reduce) { .frame b { animation: none } }
  /* The torch sits in the bottom-right, out of the frame it is there to light.
     Centred under the barcode it was the one thing guaranteed to be under your
     thumb when you were trying to line something up. */
  /* It IS in the flow, as its own row above the note and the manual-entry
     link. Taken out of the flow it was positioned against the container while
     the note and link were bottom-packed by their own margin — two schemes
     that knew nothing about each other, and they landed in the same strip:
     6px between the circle and the end of «…Ввести код вручну». A bigger
     `bottom` would only have moved the collision, since the note grows upward
     when it wraps. Owning a row means it cannot happen. `margin-top:auto`
     lives here now, not on .cam-note, so this is what absorbs the free space. */
  .cam-tools { align-self: flex-end; margin: auto 20px 24px 0; z-index: 4;
               transition: transform .2s; }
  .cam-tools button { width: 56px; height: 56px; border-radius: 50%; border: 0;
              background: rgba(255,255,255,.35);
              color: #fff; display: flex; align-items: center; justify-content: center;
              font-family: inherit; cursor: pointer; transition: transform .12s;
              -webkit-tap-highlight-color: transparent; }
  .cam-tools button:active { transform: scale(.93); }
  .cam-tools button.on { background: #fff; color: var(--cam); }
  .cam-note { margin-top: 22px; color: rgba(232,235,217,.66);
              font-size: 13px; font-weight: 600; text-align: center;
              padding: 0 24px; min-height: 18px; }
  /* Typing the digits is what you do when scanning has failed — a fallback,
     not a second way to scan, so it is a line of text and not a control
     competing with the torch. */
  .cam-link { display: block; margin: 10px 0 0; text-align: center;
              color: rgba(232,235,217,.8); font-size: 13.5px; font-weight: 700;
              text-decoration: underline; text-underline-offset: 3px; }
  /* report chooser: the options are a menu, the primary action is one button,
     and dismiss is a quiet link — three levels, not five identical buttons */
  .report-menu { margin: 2px 0 4px; }
  .report-menu a { display: flex; justify-content: space-between; align-items: center;
                   padding: 14px 2px; font-size: 15px; font-weight: 600;
                   color: var(--ink); border-top: 1px solid var(--inset-ln);
                   cursor: pointer; }
  .report-menu a:first-child { border-top: none; }
  .report-menu a::after { content: '›'; color: var(--muted); font-size: 20px;
                          font-weight: 700; line-height: 1; }
  .report-cancel { display: block; text-align: center; margin-top: 12px; padding: 6px;
                   color: var(--muted); font-size: 14px; font-weight: 700;
                   cursor: pointer; text-decoration: underline;
                   text-underline-offset: 3px; }

  /* ── label capture: this screen doing a second job ────────────────────────
     Asking for a photo used to hand the user to the OS camera, which put the
     instruction on a screen they had just left — they arrived in a camera app
     with nothing telling them which part of the pack to point at. The camera
     is already open and already pointed at the product, so nothing needs to
     be handed anywhere: the stream, the torch and the frame all stay, and only
     the frame's shape, the line above it and the bar below it change.
     Resolution is not the reason to prefer the OS camera either — the server
     caps every photo at 1600px, which the 1920-wide stream already exceeds. */
  .cam-shot { display: none; }
  /* margin-top:auto, not "last in the column": .cam-ui is a fixed-height flex
     box that CLIPS, so a taller pill or a taller frame above pushed the
     shutter out of the viewfinder entirely and it read as being behind the tab
     bar. Pinned to the bottom, it survives whatever grows above it. */
  .cam-ui.label .cam-shot { display: grid; align-items: center; width: 100%;
              grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
              margin-top: auto; padding: 0 22px 26px; gap: 12px; }
  .cam-shot .done { justify-self: end; }
  /* upright and large: what fills this frame fills the photo, because the shot
     is the whole video frame — the frame is guidance, not a crop, so a label
     that only half-fills it is a label at half the resolution the model gets */
  /* No fixed height: it takes what is left after the controls have theirs, up
     to the size it wants. Whatever pushes the column down — a notch, a taller
     pill, a bigger system font — comes out of the frame, never out of the
     shutter row underneath it. */
  .cam-ui.label .frame { width: calc(100% - 84px); flex: 1 1 auto;
              min-height: 120px; max-height: min(44vh, 340px); margin-top: 24px; }
  /* the sweep line means "searching"; here the user is aiming, not waiting */
  .cam-ui.label .frame b { display: none; }
  .cam-ui.label .cam-tools { margin-bottom: 14px; }
  .cam-shot .thumbs { flex: 1; display: flex; gap: 6px; min-width: 76px; }
  .cam-shot .thumbs img { width: 44px; height: 44px; border-radius: 9px;
              object-fit: cover; border: 2px solid rgba(255,255,255,.85); }
  .shutter { width: 72px; height: 72px; border-radius: 50%; flex-shrink: 0;
             border: 4px solid rgba(255,255,255,.9); background: #fff;
             cursor: pointer; -webkit-tap-highlight-color: transparent;
             transition: transform .12s, opacity .2s; }
  .shutter:active { transform: scale(.92); }
  /* dimmed, not hidden: a control that vanishes reads as a bug, and the line
     under the frame is already saying what to fix */
  .shutter:disabled { opacity: .35; background: rgba(255,255,255,.55); }
  .cam-shot .done { flex: 1; min-width: 76px; text-align: right; border: 0;
             background: none; color: #fff; font-family: inherit; font-size: 15px;
             font-weight: 900; cursor: pointer; visibility: hidden;
             -webkit-tap-highlight-color: transparent; }
  .cam-shot .done.on { visibility: visible; }
  /* confirmation that the shutter fired, on a screen with no shutter sound */
  .cam-ui.flash::after { content: ''; position: fixed; inset: 0; background: #fff;
             pointer-events: none; z-index: 9; animation: flash .18s ease-out; }
  @keyframes flash { from { opacity: .85 } to { opacity: 0 } }

  /* the scanned product, over the live camera: enough to decide, one tap to
     the full sheet. The donut is the same component the sheet uses — one
     visual language for a score, never a second one down here. */
  /* bottom padding clears the fixed tab bar; #app's own padding does most of
     it, this is breathing room */
  .peek { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
          background: var(--surface); border-radius: 24px 24px 0 0;
          padding: 14px 18px 24px; display: flex; flex-direction: column; gap: 12px;
          box-shadow: 0 -8px 30px rgba(0,0,0,.3); animation: rise .22s ease-out;
          touch-action: none; }
  @keyframes rise { from { transform: translateY(100%) } to { transform: none } }
  /* ── read-along notifications: a peek card sliding DOWN from the top when a
     backgrounded label read lands. They stack, newest on top, tap to open. ── */
  #rnHost { position: fixed; top: 0; left: 0; right: 0; z-index: 60;
    display: flex; flex-direction: column; align-items: center; gap: 8px;
    padding: calc(env(safe-area-inset-top) + 10px) 10px 0; pointer-events: none; }
  .rnote { pointer-events: auto; width: 100%; max-width: 460px; display: flex;
    align-items: center; gap: 12px; background: var(--surface); border-radius: 16px;
    padding: 10px 12px; box-shadow: 0 10px 30px rgba(0,0,0,.22); cursor: pointer;
    transform: translateY(-160%); opacity: 0;
    transition: transform .34s cubic-bezier(.2,.9,.3,1), opacity .3s; }
  .rnote.in { transform: translateY(0); opacity: 1; }
  .rnote.dragging { transition: none; }
  .rn-ring { position: relative; flex: 0 0 auto; width: 46px; height: 46px;
    display: flex; align-items: center; justify-content: center; }
  .rn-ring .v { position: absolute; font-weight: 800; font-size: 15px; }
  .rn-nm { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
  .rn-nm b { font-size: 14px; font-weight: 700; color: var(--ink);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .rn-nm > span { font-size: 12px; color: var(--faint);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .rn-nm .pill { align-self: flex-start; color: #fff; font-size: 11px;
    font-weight: 700; padding: 2px 9px; border-radius: 999px; }
  .rn-x { flex: 0 0 auto; background: none; border: 0; color: var(--faint);
    padding: 6px; cursor: pointer; display: flex; }
  .rn-spin { flex: 0 0 auto; width: 40px; height: 40px; border-radius: 50%;
    border: 3px solid var(--line); border-top-color: var(--green);
    animation: rnspin 1s linear infinite; }
  @keyframes rnspin { to { transform: rotate(360deg); } }
  .rn-ic { flex: 0 0 auto; width: 40px; display: flex; justify-content: center; color: #E23; }
  @media (prefers-reduced-motion: reduce) {
    .rnote { transition: opacity .2s; }
    .rnote:not(.in) { transform: none; }
    .rn-spin { animation: none; }
  }
  /* The grip is a promise. It has looked draggable since the card existed and
     did nothing when pulled, which reads as broken rather than decorative —
     an affordance offered and then withdrawn is worse than none. It now moves:
     up to the full sheet, down to dismiss and keep scanning, which are the
     only two places the card sits between.
     touch-action: none because we own the vertical gesture; without it Safari
     claims the drag as a page pan and the card stutters behind the finger. */
  .peek .grip { width: 40px; height: 4px; border-radius: 2px;
                background: #D8D9CC; margin: 0 auto; cursor: grab; }
  /* The rise animation writes `transform` too, so a drag that starts inside
     its 220ms would be overruled by it — the card would ignore the first
     finger of every fast scan. */
  .peek.dragging { animation: none; }
  .peek.dragging .grip { cursor: grabbing; }
  /* animation:none as well as the transition. .dragging was the only rule
     holding the entrance animation off, so releasing the card restored it and
     the card played its slide-UP again while the transition slid it DOWN —
     the two motions the dismiss appeared to have. */
  .peek.settling { transition: transform .2s ease-out; animation: none; }
  @media (prefers-reduced-motion: reduce) {
    .peek { animation: none }
    .peek.settling { transition: none }
  }
  .peek .row { display: flex; align-items: center; gap: 14px; }
  .peek .row img, .peek .row .ph { width: 56px; height: 56px; border-radius: 14px;
          object-fit: contain; background: var(--inset); flex-shrink: 0;
          display: flex; align-items: center; justify-content: center; color: var(--faint); }
  .peek .nm { flex: 1; min-width: 0; }
  .peek .nm b { display: block; font-size: 16px; font-weight: 900; color: var(--ink);
                line-height: 1.25; }
  .peek .nm span { display: block; font-size: 13px; color: var(--muted); font-weight: 600; }
  /* `span.pill` and not `.pill`: the rule above matches every span in .nm and
     would otherwise win on specificity and stretch the badge across the card */
  .peek .nm span.pill { display: inline-block; margin-top: 5px; color: #fff;
                font-size: 11.5px; font-weight: 900; padding: 3px 10px; border-radius: 999px; }
  /* the one line of reasoning, in the same inset the sheet uses for it — a
     bare number tells you nothing you can act on, and testers were opening
     «Детальніше» purely to find out why. Same words as the sheet, so the two
     screens cannot say different things about the same product. */
  .peek .why { margin: 0; font-size: 13px; padding: 8px 11px; }
  /* The evidence behind the number, in the same colours and against the same
     thresholds the sheet uses — one sentence was read as "there must be more
     under «Детальніше»" and sent people through a tap to find out. A row is
     one line: what it is, how far along the traffic light, how much. */
  .peek .hl { display: flex; flex-direction: column; gap: 8px;
              background: var(--inset); border-radius: 10px; padding: 10px 8px; }
  /* Every column but the name is fixed, so the name gets whatever is left —
     and on iOS, which sets Cyrillic wider than macOS Chrome does at the same
     size, what was left was not enough and «Насичені жири» came out as
     «Насичені ж…». The gaps, the paddings and the three fixed columns each
     gave back a few pixels rather than any one of them being gutted. */
  .peek .hrow { display: flex; align-items: center; gap: 5px; font-size: 12.5px; }
  .peek .hrow .hi { flex-shrink: 0; display: flex; }
  .peek .hrow .hn { flex: 1; min-width: 0; font-weight: 700; color: var(--ink-2);
                    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* Its own column, not riding inline after the name: with the amount tucked
     against a name of whatever length, «1.9 г», «0.25 г» and «0.45 г» landed
     at three different x positions and the rows read as ragged. */
  /* 64px because «644 ккал» measures 56 here and iOS sets it wider; at 54 it
     wrapped and made that row twice the height of its neighbours. nowrap so a
     column that is still too narrow somewhere spills rather than doubling the
     row — a rendering that is slightly tight beats one that is broken. */
  .peek .hrow .hv { width: 64px; flex-shrink: 0; text-align: right;
                    white-space: nowrap; font-weight: 800; color: var(--ink);
                    font-variant-numeric: tabular-nums; }
  /* Four segments, one per traffic-light band, lit up to the band the value is
     in. A continuous track was the obvious thing and it was wrong at this
     size: 0.25 г of saturated fat filled 2% of 52px — one pixel — so every
     low row rendered as an empty grey box and the gauge said nothing at all
     for exactly the products where the answer is "this is fine". A segment is
     never invisible, and banding is all the precision a glance can use. */
  .peek .hrow .hg { display: flex; gap: 3px; flex-shrink: 0; }
  .peek .hrow .hg i { display: block; width: 8px; height: 7px;
                      border-radius: 2px; background: rgba(0,0,0,.09); }
  /* the verdict, last, where the eye lands */
  /* 84px: «дуже висока» is the longest at 73 here, and iOS again sets wider */
  .peek .hrow .hw { width: 84px; flex-shrink: 0; text-align: right;
                    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
                    font-weight: 800; font-size: 11.5px; }
  /* «помірний ризик» does not fit that column and broke over two lines. The
     additives row has no gauge, so it can spend that width on the word. */
  .peek .hrow .hw.wide { width: auto; white-space: nowrap; }
  .peek .hrow.add .hn { flex: 0 0 auto; }
  .peek .hrow.add .ha { flex: 1; min-width: 0; font-weight: 800; font-size: 11.5px;
                    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .peek .hmiss { font-size: 11.5px; font-weight: 600; color: var(--muted); }
  .peek .acts { display: flex; gap: 8px; }
  .peek .acts button { flex: 1; border: 0; border-radius: 12px; padding: 11px 0;
                font-size: 13px; font-weight: 800; font-family: inherit; cursor: pointer;
                background: var(--sunken); color: var(--ink-2); }
  .peek .acts button.pri { background: var(--green); color: #fff; font-weight: 900; }
  /* A scan that finds nothing must not throw the user onto another screen.
     Looking-up, not-found and no-data all answer in the same card the good
     scans answer in, over the same viewfinder, with the action right there.
     Only «Детальніше» leaves for the full sheet. */
  .peek .busy { display: flex; align-items: center; gap: 12px;
                font-size: 14.5px; font-weight: 700; color: var(--ink-2); padding: 4px 2px; }
  .peek .busy i { width: 18px; height: 18px; flex: none; border-radius: 50%;
                  border: 2.5px solid var(--line); border-top-color: var(--green);
                  animation: peekspin .8s linear infinite; }
  @keyframes peekspin { to { transform: rotate(360deg) } }
  .notfood { display: flex; flex-direction: column; align-items: center;
             text-align: center; gap: 6px; margin: 18px 0 22px; }
  /* Shrink on a short viewport so the whole card — message and both buttons —
     stays on screen; full size where there is room. */
  .notfood img { width: auto; height: min(132px, 22vh); }
  .notfood b { font-size: 20px; font-weight: 900; color: var(--ink); line-height: 1.3; }
  /* A real, legible button — distinct from the filled «Сканувати наступний» by
     being OUTLINED, not by being shrunk into grey text. */
  .notfood .reportbtn { margin-top: 16px; padding: 14px 28px; min-height: 48px;
             border: 2px solid var(--line); border-radius: 13px;
             background: transparent; color: var(--ink);
             font-size: 16px; font-weight: 800; font-family: inherit; cursor: pointer; }
  .peek .miss .mascot { width: 52px; height: auto; flex: none; }
  .peek .miss { display: flex; align-items: center; gap: 13px; }
  .peek .miss .qm { width: 52px; height: 52px; flex: none; border-radius: 50%;
                  border: 3px dashed #D8D9CC; display: grid; place-items: center;
                  font-size: 21px; font-weight: 900; color: var(--faint); }
  .peek .miss b { display: block; font-size: 16px; font-weight: 900; color: var(--ink);
                  line-height: 1.25; }
  .peek .miss span { display: block; font-size: 13px; color: var(--muted);
                  font-weight: 600; margin-top: 2px; }
  @media (prefers-reduced-motion: reduce) { .peek .busy i { animation: none } }
